diff --git a/e2e/organization.spec.ts b/e2e/organization.spec.ts index 6df3ba34..a26ef4c8 100644 --- a/e2e/organization.spec.ts +++ b/e2e/organization.spec.ts @@ -36,8 +36,20 @@ async function createTimeEntry(page, duration: string) { test('test that organization name can be updated', async ({ page }) => { await goToOrganizationSettings(page); await page.getByLabel('Organization Name').fill('NEW ORG NAME'); - await page.getByLabel('Organization Name').press('Enter'); - await page.getByLabel('Organization Name').press('Meta+r'); + await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/api/v1/organizations/') && + response.request().method() === 'PUT' && + response.status() === 200 + ), + page + .locator('form') + .filter({ hasText: 'Organization Name' }) + .getByRole('button', { name: 'Save' }) + .click(), + ]); + await page.reload(); await expect(page.locator('[data-testid="organization_switcher"]:visible')).toContainText( 'NEW ORG NAME' ); @@ -369,6 +381,124 @@ test('test that format settings persist after page reload', async ({ page }) => await expect(page.getByLabel('Date Format')).toContainText('DD/MM/YYYY'); }); +// ============================================= +// Create, Delete & Switch +// ============================================= + +test.describe('Organization Create, Delete & Switch', () => { + async function createOrganization(page, name: string) { + await page.goto(PLAYWRIGHT_BASE_URL + '/teams/create'); + await page.getByLabel('Organization Name').fill(name); + await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/api/v1/organizations') && + response.request().method() === 'POST' && + response.status() === 201 + ), + page.getByRole('button', { name: 'Create' }).click(), + ]); + // The backend switches the current organization to the new one and the + // frontend reloads into its dashboard. + await expect(page.getByTestId('dashboard_view')).toBeVisible({ timeout: 10000 }); + } + + test('can create a new organization and switches to it automatically', async ({ page }) => { + const newOrgName = 'CreateOrg' + Math.floor(Math.random() * 100000); + await createOrganization(page, newOrgName); + + await expect(page.locator('[data-testid="organization_switcher"]:visible')).toContainText( + newOrgName + ); + }); + + test('does not create an organization when the name is empty', async ({ page }) => { + await page.goto(PLAYWRIGHT_BASE_URL + '/teams/create'); + + // The form posts to the API, which rejects the empty name with a 422. + await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/api/v1/organizations') && + response.request().method() === 'POST' && + response.status() === 422 + ), + page.getByRole('button', { name: 'Create' }).click(), + ]); + + // Validation failed, so we stay on the create form and never reach a + // dashboard. ('/teams/create' redirects to '/organizations/create', so + // assert on the form rather than the URL.) + await expect(page.getByText('Organization Details')).toBeVisible(); + await expect(page.getByRole('alert')).toContainText('The name field is required.'); + await expect(page.getByLabel('Organization Name')).toHaveAttribute('aria-invalid', 'true'); + await expect(page.getByTestId('dashboard_view')).toHaveCount(0); + }); + + test('can delete an organization', async ({ page }) => { + // Create a throwaway organization so the primary one is never deleted. + const orgName = 'DeleteOrg' + Math.floor(Math.random() * 100000); + await createOrganization(page, orgName); + + // Open the (now current) throwaway organization's settings. + await goToOrganizationSettings(page); + + // Open the confirmation modal, then confirm inside the dialog. + await page.getByRole('button', { name: 'Delete Organization' }).click(); + await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/api/v1/organizations') && + response.request().method() === 'DELETE' && + response.status() === 204 + ), + page.getByRole('dialog').getByRole('button', { name: 'Delete Organization' }).click(), + ]); + + // We are redirected to the dashboard of a different organization. + await expect(page.getByTestId('dashboard_view')).toBeVisible({ timeout: 10000 }); + await expect( + page.locator('[data-testid="organization_switcher"]:visible') + ).not.toContainText(orgName); + }); + + test('can switch the current organization via the organization switcher', async ({ page }) => { + await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard'); + const orgSwitcher = page.locator('[data-testid="organization_switcher"]:visible'); + await expect(orgSwitcher).toBeVisible(); + const previousOrgNameLines = (await orgSwitcher.innerText()) + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); + const previousOrgName = previousOrgNameLines[previousOrgNameLines.length - 1]; + + // Ensure there are at least two organizations to switch between. + const orgName = 'SwitchOrg' + Math.floor(Math.random() * 100000); + await createOrganization(page, orgName); + + await expect(orgSwitcher).toContainText(orgName); + + // Open the switcher and pick a different organization. + await orgSwitcher.click(); + await expect(page.getByText('Switch Organizations')).toBeVisible(); + const otherOrgButton = page.getByRole('menuitem', { name: previousOrgName }); + await expect(otherOrgButton).toBeVisible(); + + await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/users/me/current-organization') && + response.request().method() === 'PUT' && + response.status() === 200 + ), + otherOrgButton.click(), + ]); + + await expect(orgSwitcher).not.toContainText(orgName, { timeout: 10000 }); + await expect(orgSwitcher).toContainText(previousOrgName, { timeout: 10000 }); + }); +}); + // ============================================= // Admin Permission Tests // ============================================= diff --git a/resources/js/Pages/Profile/Partials/UpdateProfileInformationForm.vue b/resources/js/Pages/Profile/Partials/UpdateProfileInformationForm.vue index b4d34af3..e1957e07 100644 --- a/resources/js/Pages/Profile/Partials/UpdateProfileInformationForm.vue +++ b/resources/js/Pages/Profile/Partials/UpdateProfileInformationForm.vue @@ -1,7 +1,6 @@ @@ -59,8 +69,8 @@ const deleteTeam = () => { Delete Organization diff --git a/resources/js/Pages/Teams/Partials/UpdateTeamNameForm.vue b/resources/js/Pages/Teams/Partials/UpdateTeamNameForm.vue index fa71caac..be7e8ca5 100644 --- a/resources/js/Pages/Teams/Partials/UpdateTeamNameForm.vue +++ b/resources/js/Pages/Teams/Partials/UpdateTeamNameForm.vue @@ -1,5 +1,7 @@ @@ -74,7 +120,7 @@ const updateTeamName = () => { class="block w-full" :disabled="!permissions.canUpdateTeam" /> - {{ form.errors.name }} + {{ errors.name }} @@ -94,14 +140,14 @@ const updateTeamName = () => { {{ currencyKey }} - {{ currencyTranslated }} - {{ form.errors.currency }} + {{ errors.currency }} diff --git a/resources/js/packages/api/src/openapi.json.client.ts b/resources/js/packages/api/src/openapi.json.client.ts index 3800fbf5..564e516a 100644 --- a/resources/js/packages/api/src/openapi.json.client.ts +++ b/resources/js/packages/api/src/openapi.json.client.ts @@ -803,6 +803,39 @@ const endpoints = makeApi([ z.object({ code: z.string(), name: z.string(), symbol: z.string() }).passthrough() ), }, + { + method: 'post', + path: '/v1/organizations', + alias: 'createOrganization', + requestFormat: 'json', + parameters: [ + { + name: 'body', + type: 'Body', + schema: z.object({ name: z.string().max(255) }).passthrough(), + }, + ], + response: z.object({ data: OrganizationResource }).passthrough(), + errors: [ + { + status: 401, + description: `Unauthenticated`, + schema: z.object({ message: z.string() }).passthrough(), + }, + { + status: 403, + description: `Authorization error`, + schema: z.object({ message: z.string() }).passthrough(), + }, + { + status: 422, + description: `Validation error`, + schema: z + .object({ message: z.string(), errors: z.record(z.array(z.string())) }) + .passthrough(), + }, + ], + }, { method: 'get', path: '/v1/organizations/:organization', @@ -877,6 +910,37 @@ const endpoints = makeApi([ }, ], }, + { + method: 'delete', + path: '/v1/organizations/:organization', + alias: 'deleteOrganization', + requestFormat: 'json', + parameters: [ + { + name: 'organization', + type: 'Path', + schema: z.string(), + }, + ], + response: z.void(), + errors: [ + { + status: 401, + description: `Unauthenticated`, + schema: z.object({ message: z.string() }).passthrough(), + }, + { + status: 403, + description: `Authorization error`, + schema: z.object({ message: z.string() }).passthrough(), + }, + { + status: 404, + description: `Not found`, + schema: z.object({ message: z.string() }).passthrough(), + }, + ], + }, { method: 'get', path: '/v1/organizations/:organization/charts/daily-tracked-hours', @@ -4495,6 +4559,42 @@ The report is considered public if the `is_public` field is set to &#x }, ], }, + { + method: 'put', + path: '/v1/users/me/current-organization', + alias: 'updateMyCurrentOrganization', + description: `Switches the organization that the user is currently working in. The user +must be a member of the given organization. This endpoint is independent of +the organization.`, + requestFormat: 'json', + parameters: [ + { + name: 'body', + type: 'Body', + schema: z.object({ organization_id: z.string().uuid() }).passthrough(), + }, + ], + response: z.object({ data: UserResource }).passthrough(), + errors: [ + { + status: 401, + description: `Unauthenticated`, + schema: z.object({ message: z.string() }).passthrough(), + }, + { + status: 403, + description: `Authorization error`, + schema: z.object({ message: z.string() }).passthrough(), + }, + { + status: 422, + description: `Validation error`, + schema: z + .object({ message: z.string(), errors: z.record(z.array(z.string())) }) + .passthrough(), + }, + ], + }, { method: 'put', path: '/v1/users/:user', diff --git a/resources/js/utils/apiValidation.ts b/resources/js/utils/apiValidation.ts new file mode 100644 index 00000000..fcc06853 --- /dev/null +++ b/resources/js/utils/apiValidation.ts @@ -0,0 +1,31 @@ +import axios, { type AxiosError } from 'axios'; + +type ApiValidationResponse = { + message?: string; + errors?: Record; +}; + +export function isApiValidationError(error: unknown): error is AxiosError { + return axios.isAxiosError(error) && error.response?.status === 422; +} + +export function getApiValidationFieldErrors(error: unknown): Record { + if (!isApiValidationError(error)) { + return {}; + } + + const fieldErrors: Record = {}; + for (const [field, messages] of Object.entries(error.response?.data?.errors ?? {})) { + if (Array.isArray(messages) && messages[0]) { + fieldErrors[field] = messages[0]; + } + } + return fieldErrors; +} + +export function getApiValidationMessage(error: unknown, fallback: string): string { + if (!isApiValidationError(error)) { + return fallback; + } + return error.response?.data?.message ?? fallback; +} diff --git a/resources/js/utils/useOrganization.ts b/resources/js/utils/useOrganization.ts index ec5b18d5..3139ba92 100644 --- a/resources/js/utils/useOrganization.ts +++ b/resources/js/utils/useOrganization.ts @@ -11,23 +11,29 @@ import { useNotificationsStore } from '@/utils/notification'; import { getCurrentOrganizationId } from '@/utils/useUser'; import { api } from '@/packages/api/src'; -export function switchOrganization(organizationId: string) { - // Clear Inertia's prefetch cache to prevent stale pages from the old - // organization being served when navigating after the switch. - router.flushAll(); +export async function switchOrganization(organizationId: string) { + const { handleApiRequestNotifications } = useNotificationsStore(); + try { + await handleApiRequestNotifications( + () => api.updateMyCurrentOrganization({ organization_id: organizationId }), + undefined, + 'Failed to switch organization' + ); + } catch { + // The error notification is surfaced by the request handler. + return; + } - router.put( - route('current-team.update'), - { - team_id: organizationId, + // The current organization changed server-side. Clear Inertia's prefetch + // cache and reload into the dashboard so the new organization context + // (auth.user.current_team) is picked up everywhere. + router.flushAll(); + router.visit(route('dashboard'), { + preserveState: false, + onSuccess: () => { + initializeStores(); }, - { - preserveState: false, - onSuccess: () => { - initializeStores(); - }, - } - ); + }); } export const useOrganizationStore = defineStore('organization', () => { @@ -67,9 +73,33 @@ export const useOrganizationStore = defineStore('organization', () => { } } + async function createOrganization(name: string): Promise { + const response = await api.createOrganization({ name }); + return response?.data ?? null; + } + + async function deleteOrganization(organizationId: string) { + await handleApiRequestNotifications( + () => + api.deleteOrganization(undefined, { + params: { + organization: organizationId, + }, + }), + 'Organization deleted successfully', + 'Failed to delete organization' + ); + } + const organization = computed(() => { return organizationResponse.value?.data || null; }); - return { organization, fetchOrganization, updateOrganization }; + return { + organization, + fetchOrganization, + updateOrganization, + createOrganization, + deleteOrganization, + }; });