Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions ERRORS.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,4 @@ Use this file as a compact memory of recurring AI mistakes.
- [2026-03-15] pr scope: batching multiple unrelated fixes in one PR -> one fix = one PR to isolate blast radius and reduce iteration loops
- [2026-05-09] update-stack: adding `import './modules/foo/styles/x.css'` to src/main.js downstream -> moved to project view (`src/modules/{project}/views/{project}.view.vue`). Stack-managed entry; --theirs wipes silently. See pierreb-devkit/Vue#4093.
- [2026-06-15] npm audit: treating the 3 advisories (brace-expansion+ip-address moderate, picomatch high) as a runtime exposure -> all are transitive devDependencies (`npm audit --omit=dev` = 0); track upgrades, do not panic-patch the runtime bundle.
- [2026-07-16] auth: swapping a throwing store action (`refreshAbilities()`) for a silently-swallowing one (`token()`, never throws) without preserving the caller's bail-out -> after an `await token()` soft-refresh, check store state (e.g. `!authStore.user`) the way sibling guards do (`app.router.js`'s `isLoggedIn && !user`) before proceeding with UI that assumes the refresh succeeded. See #4447 (verifyEmail.view.vue), follow-up to #4431.
22 changes: 22 additions & 0 deletions src/modules/auth/tests/auth.verifyEmail.view.unit.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -167,5 +167,27 @@ describe('auth.verifyEmail.view', () => {
expect(wrapper.vm.success).toBe(true);
expect(wrapper.vm.$router.push).not.toHaveBeenCalled();
});

// #4447 — the refreshAbilities() -> token() swap (#4431) dropped the
// "stay on page if the refresh fails" bail-out, since token() never
// throws. A failed soft-refresh leaves the store without a populated
// user; the view must stay on the verified-success page instead of
// showing "Redirecting..." and navigating on stale state.
it('stays on page when the post-verification soft-refresh fails to populate a user', async () => {
storeMock.isLoggedIn = true;
storeMock.user = null; // token() swallowed a failure, left no user
storeMock.serverConfig = { organizations: { enabled: true } };
verifyEmailMock.mockResolvedValueOnce({ message: 'Email verified' });

const wrapper = mountView();
await wrapper.vm.$nextTick();
await vi.dynamicImportSettled();

expect(tokenMock).toHaveBeenCalledTimes(1);
expect(wrapper.vm.success).toBe(true);
expect(wrapper.vm.redirecting).toBe(false);
expect(wrapper.vm.$router.push).not.toHaveBeenCalled();
expect(wrapper.text()).toContain('Your email has been verified successfully. You can now sign in.');
});
});
});
9 changes: 9 additions & 0 deletions src/modules/auth/views/verifyEmail.view.vue
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ export default {
* @desc Redirect the user after successful email verification based on auth state.
* - Logged in + no org + orgs enabled → /organization-required
* - Logged in + has org → home route
* - Logged in but soft-refresh fails (no user in store) → stay on page
* - Not logged in → stay on page with sign-in link
* @param {Object} authStore - The auth store instance.
* @returns {Promise<void>}
Expand All @@ -107,6 +108,14 @@ export default {
// refreshAbilities() signs out + rethrows on failure, which would eject
// the user who just verified their email.
await authStore.token();
if (!authStore.user) {
// token() swallows failures internally, so a failed soft-refresh
// leaves the store without a populated user (same isLoggedIn +
// !user signal the app.router.js guard checks). Stay on this page
// with the verified-success message instead of showing
// "Redirecting..." and navigating on stale state.
return;
}
this.redirecting = true;
const serverConfig = authStore.serverConfig || (await authStore.fetchServerConfig());
if (!authStore.user?.currentOrganization && serverConfig?.organizations?.enabled) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,87 @@ describe('organization.create.view — first-org redirect (#4422)', () => {
expect(push).toHaveBeenCalledWith('/tasks');
});
});

// #4447 — the catch block only logged to the console, leaving the user with
// no feedback on a failed create. Surface the backend message via the error
// alert, mirroring organizationSetup.component.vue (f92003d5).
describe('organization.create.view — error surfacing (#4447)', () => {
beforeEach(() => {
setActivePinia(createPinia());
push.mockReset();
tokenMock.mockReset().mockResolvedValue();
createOrganizationMock.mockReset().mockResolvedValue({ id: 'org-9' });
authStoreMock.user = { id: 'u1' };
});

it('sets error from response data message on createOrganization failure and resets loading', async () => {
const apiError = { response: { data: { message: 'An organization with this name already exists' } } };
createOrganizationMock.mockRejectedValueOnce(apiError);

const wrapper = mountView();
wrapper.vm.name = 'Acme';
await wrapper.vm.create();
await flushPromises();

expect(wrapper.vm.error).toBe('An organization with this name already exists');
expect(wrapper.vm.loading).toBe(false);
expect(push).not.toHaveBeenCalled();
});

it('renders the error alert with the message in the DOM', async () => {
const apiError = { response: { data: { message: 'An organization with this name already exists' } } };
createOrganizationMock.mockRejectedValueOnce(apiError);

const wrapper = mountView();
wrapper.vm.name = 'Acme';
await wrapper.vm.create();
await flushPromises();
await wrapper.vm.$nextTick();

expect(wrapper.text()).toContain('An organization with this name already exists');
});

it('falls back to err.message when a create failure has no response data message', async () => {
createOrganizationMock.mockRejectedValueOnce(new Error('Network error'));

const wrapper = mountView();
wrapper.vm.name = 'Acme';
await wrapper.vm.create();
await flushPromises();

expect(wrapper.vm.error).toBe('Network error');
});

it('falls back to a generic message when the error has no message', async () => {
createOrganizationMock.mockRejectedValueOnce({});

const wrapper = mountView();
wrapper.vm.name = 'Acme';
await wrapper.vm.create();
await flushPromises();

expect(wrapper.vm.error).toBe('Could not create organization. Please try again.');
});

it('clears a stale error at the start of a new create attempt', async () => {
const wrapper = mountView();
wrapper.vm.error = 'Could not create organization. Please try again.';

createOrganizationMock.mockResolvedValueOnce({ id: 'org-9' });
wrapper.vm.name = 'Acme';
await wrapper.vm.create();
await flushPromises();

expect(wrapper.vm.error).toBeNull();
});

it('leaves no error on a successful create', async () => {
const wrapper = mountView();
wrapper.vm.name = 'Acme';
await wrapper.vm.create();
await flushPromises();

expect(wrapper.vm.error).toBeNull();
expect(push).toHaveBeenCalled();
});
});
18 changes: 17 additions & 1 deletion src/modules/organizations/views/organization.create.view.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,20 @@
<v-col cols="12" sm="10" md="8" lg="6">
<v-card class="pa-8" color="surface" :flat="config.vuetify.theme.flat" :class="config.vuetify.theme.rounded">
<h3 class="text-title-large font-weight-medium mb-6">Organization Details</h3>

<!-- Error alert -->
<v-alert
v-if="error"
type="error"
variant="tonal"
class="mb-4"
:class="config.vuetify.theme.rounded"
closable
@click:close="error = null"
>
{{ error }}
</v-alert>

<v-form ref="form" v-model="valid">
<v-text-field
v-model="name"
Expand Down Expand Up @@ -58,6 +72,7 @@ export default {
return {
valid: false,
loading: false,
error: null,
name: '',
description: '',
rules: { required: (v) => (!!v && !!v.trim()) || 'Required' },
Expand All @@ -75,6 +90,7 @@ export default {
const form = await this.$refs.form.validate();
if (form.valid) {
this.loading = true;
this.error = null;
const organizationsStore = useOrganizationsStore();
const authStore = useAuthStore();
// No current org (a first org, or a management user who just deleted their
Expand All @@ -98,7 +114,7 @@ export default {
);
}
} catch (err) {
console.error(err);
this.error = err?.response?.data?.message || err?.message || 'Could not create organization. Please try again.';
} finally {
this.loading = false;
}
Expand Down
Loading