Skip to content

Commit 8e0be0f

Browse files
feat(home+auth): promote external-link safety + org-setup error UX from trawl (#4237)
T8: add buttonTarget/buttonRel computed props to home.content.component.vue — auto-applies target=_blank + rel=noopener noreferrer for external links T9: add user-visible error alert to organizationSetup.component.vue — replaces console.error with v-alert on createOrganization failure Closes #4236. Partially closes pierreb-projects/infra#38 (T8+T9).
1 parent 7be1220 commit 8e0be0f

4 files changed

Lines changed: 138 additions & 1 deletion

File tree

src/modules/auth/components/organizationSetup.component.vue

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,19 @@
5454
</v-divider>
5555
</template>
5656

57+
<!-- Error alert -->
58+
<v-alert
59+
v-if="error"
60+
type="error"
61+
variant="tonal"
62+
class="mb-4"
63+
:class="config.vuetify.theme.rounded"
64+
closable
65+
@click:close="error = null"
66+
>
67+
{{ error }}
68+
</v-alert>
69+
5770
<!-- Create organization form -->
5871
<v-form ref="form" v-model="valid">
5972
<v-text-field
@@ -121,6 +134,7 @@ export default {
121134
loading: false,
122135
requestLoading: false,
123136
requestSent: false,
137+
error: null,
124138
organizationName: this.defaultName ? `${this.defaultName}'s organization` : '',
125139
rules: {
126140
required: (v) => (!!v && !!v.trim()) || 'Organization name is required',
@@ -136,6 +150,7 @@ export default {
136150
if (!form.valid) return;
137151
138152
this.loading = true;
153+
this.error = null;
139154
const organizationsStore = useOrganizationsStore();
140155
try {
141156
const organization = await organizationsStore.createOrganization({
@@ -145,7 +160,7 @@ export default {
145160
this.$emit('created', organization);
146161
}
147162
} catch (err) {
148-
console.error(err);
163+
this.error = err?.response?.data?.message || err?.message || 'Could not create organization. Please try again.';
149164
} finally {
150165
this.loading = false;
151166
}

src/modules/auth/tests/auth.organizationSetup.component.unit.tests.js

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,4 +124,65 @@ describe('auth.organizationSetup.component', () => {
124124

125125
expect(wrapper.vm.loading).toBe(false);
126126
});
127+
128+
// --- error alert UX (T9) ---
129+
130+
it('initializes error as null', () => {
131+
const wrapper = mountComponent();
132+
expect(wrapper.vm.error).toBeNull();
133+
});
134+
135+
it('sets error from response data message on createOrganization failure', async () => {
136+
const apiError = { response: { data: { message: 'Name already taken' } } };
137+
createOrganizationMock.mockRejectedValueOnce(apiError);
138+
139+
const wrapper = mountComponent();
140+
await flushPromises();
141+
142+
wrapper.vm.organizationName = 'Duplicate Org';
143+
await wrapper.vm.submit();
144+
145+
expect(wrapper.vm.error).toBe('Name already taken');
146+
});
147+
148+
it('falls back to err.message when no response data message', async () => {
149+
const apiError = new Error('Network error');
150+
createOrganizationMock.mockRejectedValueOnce(apiError);
151+
152+
const wrapper = mountComponent();
153+
await flushPromises();
154+
155+
wrapper.vm.organizationName = 'Test Org';
156+
await wrapper.vm.submit();
157+
158+
expect(wrapper.vm.error).toBe('Network error');
159+
});
160+
161+
it('falls back to generic message when error has no message', async () => {
162+
createOrganizationMock.mockRejectedValueOnce({});
163+
164+
const wrapper = mountComponent();
165+
await flushPromises();
166+
167+
wrapper.vm.organizationName = 'Test Org';
168+
await wrapper.vm.submit();
169+
170+
expect(wrapper.vm.error).toBe('Could not create organization. Please try again.');
171+
});
172+
173+
it('clears error at the start of a new submit', async () => {
174+
const wrapper = mountComponent();
175+
await flushPromises();
176+
177+
// First submission fails
178+
createOrganizationMock.mockRejectedValueOnce(new Error('First error'));
179+
wrapper.vm.organizationName = 'Test Org';
180+
await wrapper.vm.submit();
181+
expect(wrapper.vm.error).toBe('First error');
182+
183+
// Second submission succeeds — error must be cleared
184+
createOrganizationMock.mockResolvedValueOnce({ name: 'Test Org', _id: '456' });
185+
await wrapper.vm.submit();
186+
expect(wrapper.vm.error).toBeNull();
187+
});
127188
});

src/modules/home/components/utils/home.content.component.vue

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@
7777
>
7878
<v-btn
7979
:href="setup.button.link"
80+
:target="buttonTarget"
81+
:rel="buttonRel"
8082
variant="text"
8183
class="my-4 text-none text-body-large"
8284
:style="themeColor ? { color: setup.button.color || themeColor } : {}"
@@ -181,6 +183,15 @@ export default {
181183
}
182184
return null;
183185
},
186+
buttonTarget() {
187+
const explicit = this.setup.button?.target;
188+
if (explicit) return explicit;
189+
const link = this.setup.button?.link || '';
190+
return /^https?:\/\//i.test(link) ? '_blank' : null;
191+
},
192+
buttonRel() {
193+
return this.buttonTarget === '_blank' ? 'noopener noreferrer' : null;
194+
},
184195
},
185196
};
186197
</script>

src/modules/home/tests/home.content.component.unit.tests.js

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,4 +172,54 @@ describe('HomeContentComponent', () => {
172172
expect(validator('right')).toBe(true);
173173
expect(validator('bogus')).toBe(false);
174174
});
175+
176+
// --- buttonTarget / buttonRel (external-link safety) ---
177+
178+
it('buttonTarget returns _blank for an external https link', () => {
179+
const wrapper = mount(HomeContentComponent, {
180+
props: { setup: { ...baseSetup, button: { title: 'Go', link: 'https://example.com' } } },
181+
global: globalOpts(vuetify),
182+
});
183+
expect(wrapper.vm.buttonTarget).toBe('_blank');
184+
});
185+
186+
it('buttonTarget returns _blank for an external http link', () => {
187+
const wrapper = mount(HomeContentComponent, {
188+
props: { setup: { ...baseSetup, button: { title: 'Go', link: 'http://example.com' } } },
189+
global: globalOpts(vuetify),
190+
});
191+
expect(wrapper.vm.buttonTarget).toBe('_blank');
192+
});
193+
194+
it('buttonTarget returns null for an internal (relative) link', () => {
195+
const wrapper = mount(HomeContentComponent, {
196+
props: { setup: { ...baseSetup, button: { title: 'Go', link: '/internal' } } },
197+
global: globalOpts(vuetify),
198+
});
199+
expect(wrapper.vm.buttonTarget).toBeNull();
200+
});
201+
202+
it('buttonTarget returns the explicit target when setup.button.target is provided', () => {
203+
const wrapper = mount(HomeContentComponent, {
204+
props: { setup: { ...baseSetup, button: { title: 'Go', link: '/internal', target: '_self' } } },
205+
global: globalOpts(vuetify),
206+
});
207+
expect(wrapper.vm.buttonTarget).toBe('_self');
208+
});
209+
210+
it('buttonRel is "noopener noreferrer" when buttonTarget is _blank', () => {
211+
const wrapper = mount(HomeContentComponent, {
212+
props: { setup: { ...baseSetup, button: { title: 'Go', link: 'https://external.com' } } },
213+
global: globalOpts(vuetify),
214+
});
215+
expect(wrapper.vm.buttonRel).toBe('noopener noreferrer');
216+
});
217+
218+
it('buttonRel is null when buttonTarget is null (internal link)', () => {
219+
const wrapper = mount(HomeContentComponent, {
220+
props: { setup: { ...baseSetup, button: { title: 'Go', link: '/internal' } } },
221+
global: globalOpts(vuetify),
222+
});
223+
expect(wrapper.vm.buttonRel).toBeNull();
224+
});
175225
});

0 commit comments

Comments
 (0)