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 }}
- Saved.
+ Saved.
-
+
Save
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
},
],
},
+ {
+ 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,
+ };
});