Skip to content
Open
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
2 changes: 1 addition & 1 deletion packages/front-end/src/app/components/EGDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@
<template #header>
<div class="flex flex-col">
<div class="flex items-start gap-2">
<EGText :id="titleId" tag="h2" class="mb-6 min-w-0 flex-1 break-all">{{ primaryMessage }}</EGText>
<EGText :id="titleId" tag="h2" class="mb-6 min-w-0 flex-1 break-words">{{ primaryMessage }}</EGText>
<div class="shrink-0">
<UButton
@click="handleCancel"
Expand Down
42 changes: 42 additions & 0 deletions packages/front-end/src/app/components/EGLabView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import EGDataCollectionsPage from '@FE/components/EGDataCollectionsPage.vue';
import type { DataCollectionsTab } from '@FE/components/EGDataCollectionsTabBar.vue';
import { TableSort } from './EGTable.vue';
import { ensureLabInActiveOrg } from '@FE/utils/ensure-lab-in-active-org';

const props = defineProps<{
superuser?: boolean;
Expand Down Expand Up @@ -72,6 +73,23 @@
return `${tab.label} — ${base}`;
});

/** Prevents duplicate redirects when multiple watchers or lifecycle hooks fire. */
const hasRedirectedForOrgMismatch = ref(false);

async function redirectIfLabOrgMismatch(): Promise<boolean> {
if (hasRedirectedForOrgMismatch.value) {
return true;
}
const redirected = await ensureLabInActiveOrg({
labId: props.labId,
superuser: props.superuser,
});
if (redirected) {
hasRedirectedForOrgMismatch.value = true;
}
return redirected;
}

/** Pipeline Runs table footer; only when Settings → Run retention (months) is greater than zero. */
const runRecordsRetentionNotice = computed((): string | undefined => {
// ?? applies default-for-missing only; explicit 0 must stay 0 (footnote hidden).
Expand All @@ -85,6 +103,11 @@
*/
onBeforeMount(async () => {
await loadLabData();

if (await redirectIfLabOrgMismatch()) {
return;
}

await pollFetchLaboratoryRuns();
});

Expand Down Expand Up @@ -720,16 +743,35 @@
() => props.labId,
() => {
hasRefreshedLabForNullToken.value = false;
hasRedirectedForOrgMismatch.value = false;
lastProcessedLabRef.value = null;
void loadLabData();
},
);

watch(
() => userStore.currentOrgId,
async () => {
if (props.superuser || uiStore.isRequestPending('loadLabData')) {
return;
}
await redirectIfLabOrgMismatch();
},
);

watch(lab, async (newLab) => {
if (newLab === null) {
return;
}

if (!props.superuser && uiStore.isRequestPending('loadLabData')) {
return;
}

if (await redirectIfLabOrgMismatch()) {
return;
}

// Avoid running secondary fetches multiple times for the same lab object (e.g. re-entrant or duplicate watch runs)
if (lastProcessedLabRef.value === newLab) {
return;
Expand Down
25 changes: 21 additions & 4 deletions packages/front-end/src/app/components/EGOrgsList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@
}>();

const emit = defineEmits<{
/** Row click — select org and open Labs for that org (org admins) */
(event: 'select-org', org: Organization): void;
/** Action menu — open org management after switching context */
(event: 'manage-org', org: Organization): void;
/** @deprecated Use select-org / manage-org; kept for superuser admin list */
(event: 'click-org', org: Organization): void;
}>();

Expand Down Expand Up @@ -60,7 +65,7 @@
[
{
label: 'View / Edit',
click: async () => viewOrg(org),
click: async () => manageOrg(org),
},
],
];
Expand All @@ -82,8 +87,20 @@
return items;
}

function viewOrg(org: Organization) {
emit('click-org', org);
function selectOrg(org: Organization) {
if (props.superuser) {
emit('click-org', org);
} else {
emit('select-org', org);
}
}

function manageOrg(org: Organization) {
if (props.superuser) {
emit('click-org', org);
} else {
emit('manage-org', org);
}
}

// delete org stuff
Expand Down Expand Up @@ -123,7 +140,7 @@
</EGPageHeader>

<EGTable
:row-click-action="viewOrg"
:row-click-action="selectOrg"
:table-data="orgsDisplayList"
:columns="tableColumns"
:is-loading="isLoading"
Expand Down
27 changes: 5 additions & 22 deletions packages/front-end/src/app/components/EGSwitchOrgModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,36 +6,19 @@
}>();
const model = defineModel();

const { $api } = useNuxtApp();
const $router = useRouter();

const userStore = useUserStore();
const uiStore = useUiStore();
const { switchTo } = useSwitchOrganization();

async function doSwitchOrg(): Promise<void> {
uiStore.setRequestPending('switchOrg');
if (!props.switchToOrgId) {
return;
}

try {
userStore.mostRecentLab.LaboratoryId = null; // Reset

// update default org/lab in api
await $api.users.updateUserLastAccessInfo(userStore.currentUserDetails.id!, props.switchToOrgId, undefined);

// refresh values from api
await useAuth().getRefreshedToken();
await useUser().setCurrentUserDataFromToken();

await switchTo(props.switchToOrgId);
$router.push('/');

// I hate this delay but there's a really screwy race condition otherwise
// it causes a bad bug where the new org will open to the previous org's lab, which shouldn't even be possible
setTimeout(() => {
uiStore.incrementRemountAppKey();
useToastStore().success('You have switched organizations');
uiStore.setRequestComplete('switchOrg');
}, 100);
} catch (e) {
uiStore.setRequestComplete('switchOrg');
throw e;
}
}
Expand Down
38 changes: 38 additions & 0 deletions packages/front-end/src/app/composables/useSwitchOrganization.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* Switches the user's active organization (DefaultOrganization + JWT claims).
* Must match EGSwitchOrgModal so Labs and other org-scoped views use the correct context.
*/
export default function useSwitchOrganization() {
async function switchTo(orgId: string, options?: { showToast?: boolean }): Promise<void> {
const userStore = useUserStore();
const uiStore = useUiStore();
const { $api } = useNuxtApp();

userStore.mostRecentLab.LaboratoryId = null;

if (userStore.currentOrgId === orgId) {
return;
}

uiStore.setRequestPending('switchOrg');

try {
await $api.users.updateUserLastAccessInfo(userStore.currentUserDetails.id!, orgId, undefined);

await useAuth().getRefreshedToken();
await useUser().setCurrentUserDataFromToken();

// Avoid race where the new org opens the previous org's lab (see EG-1096)
await new Promise((resolve) => setTimeout(resolve, 100));
uiStore.incrementRemountAppKey();

if (options?.showToast !== false) {
useToastStore().success('You have switched organizations');
}
} finally {
uiStore.setRequestComplete('switchOrg');
}
}

return { switchTo };
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
DescribePipelineSchemaResponse,
Pipeline as SeqeraPipeline,
} from '@/packages/shared-lib/src/app/types/nf-tower/nextflow-tower-api';
import { ensureLabInActiveOrg } from '@FE/utils/ensure-lab-in-active-org';

const { $api } = useNuxtApp();
const $router = useRouter();
Expand All @@ -26,6 +27,12 @@
$router.push('/labs');
}

onBeforeMount(async () => {
if (await ensureLabInActiveOrg({ labId })) {
return;
}
});

// set a new seqeraRunTempId if not provided
if (!$route.query.seqeraRunTempId) {
$router.push({ query: { seqeraRunTempId: uuidv4() } });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import { ButtonVariantEnum } from '@FE/types/buttons';
import { WorkflowParameter } from '@aws-sdk/client-omics';
import { v4 as uuidv4 } from 'uuid';
import { ensureLabInActiveOrg } from '@FE/utils/ensure-lab-in-active-org';

const { $api } = useNuxtApp();
const $router = useRouter();
Expand All @@ -24,6 +25,12 @@
$router.push('/labs');
}

onBeforeMount(async () => {
if (await ensureLabInActiveOrg({ labId })) {
return;
}
});

// set a new omicsRunTempId if not provided
if (!$route.query.omicsRunTempId) {
$router.push({ query: { omicsRunTempId: uuidv4() } });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
} from '@easy-genomics/shared-lib/src/app/types/nf-tower/nextflow-tower-api';
import { RunTask, GetRunResponse } from '@aws-sdk/client-omics';
import { useLabsStore, useRunStore, useUiStore } from '@FE/stores';
import { ensureLabInActiveOrg } from '@FE/utils/ensure-lab-in-active-org';

const $route = useRoute();
const $router = useRouter();
Expand Down Expand Up @@ -84,6 +85,9 @@
const omicsRunDetail = ref<GetRunResponse | null>(null);

onBeforeMount(async () => {
if (await ensureLabInActiveOrg({ labId })) {
return;
}
await fetchLabRuns();
});

Expand Down
10 changes: 6 additions & 4 deletions packages/front-end/src/app/pages/orgs/[orgId]/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@

const userStore = useUserStore();

const orgId = $route.params.orgId as string;
const orgId = computed(() => $route.params.orgId as string);

if (!userStore.canManageOrg(orgId)) {
$router.push({ path: '/' });
}
onMounted(() => {
if (!userStore.canManageOrg(orgId.value)) {
$router.push({ path: '/' });
}
});
</script>

<template>
Expand Down
15 changes: 14 additions & 1 deletion packages/front-end/src/app/pages/orgs/index.vue
Original file line number Diff line number Diff line change
@@ -1,13 +1,26 @@
<script setup lang="ts">
import { Organization } from '@easy-genomics/shared-lib/src/app/types/easy-genomics/organization';

definePageMeta({ fullWidthContent: true });

const $router = useRouter();
const { switchTo } = useSwitchOrganization();

if (!useUserStore().canManageAnyOrgs()) {
$router.push({ path: '/' });
}

async function onSelectOrg(org: Organization): Promise<void> {
await switchTo(org.OrganizationId, { showToast: false });
await navigateTo('/');
}

async function onManageOrg(org: Organization): Promise<void> {
await switchTo(org.OrganizationId, { showToast: false });
await navigateTo(`/orgs/${org.OrganizationId}`);
}
</script>

<template>
<EGOrgsList @click-org="(org) => $router.push({ path: '/orgs/' + org.OrganizationId })" />
<EGOrgsList @select-org="onSelectOrg" @manage-org="onManageOrg" />
</template>
56 changes: 56 additions & 0 deletions packages/front-end/src/app/utils/ensure-lab-in-active-org.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { storeToRefs } from 'pinia';
import { useToastStore } from '@FE/stores';

export type EnsureLabInActiveOrgOptions = {
labId: string;
redirectTo?: string;
/** When true, bypasses the org check (e.g. admin EGLabView route). */
superuser?: boolean;
};

/**
* Ensures a lab route cannot render a lab belonging to a different org than the active org.
* The active org controls the browsing context even when the user has access to multiple orgs.
*
* Always loads fresh lab data before validating to avoid false redirects from persisted store state.
*
* @returns true if navigation away was initiated (caller should stop further work).
*/
export async function ensureLabInActiveOrg({
labId,
redirectTo = '/labs',
superuser = false,
}: EnsureLabInActiveOrgOptions): Promise<boolean> {
const userStore = useUserStore();
const labsStore = useLabsStore();
const { currentOrgId } = storeToRefs(userStore);

if (superuser || userStore.isSuperuser) {
return false;
}

try {
await labsStore.loadLab(labId);
} catch (error) {
console.error('Failed to load laboratory for org validation', error);
useToastStore().error('Failed to load laboratory');
await navigateTo(redirectTo);
return true;
}

const lab = labsStore.labs[labId];
if (!lab) {
console.error('Laboratory not found after load', { labId });
useToastStore().error('Failed to load laboratory');
await navigateTo(redirectTo);
return true;
}

const labOrgId = lab.OrganizationId ?? null;
if (!!currentOrgId.value && !!labOrgId && currentOrgId.value !== labOrgId) {
await navigateTo(redirectTo);
return true;
}

return false;
}
Loading