diff --git a/backend/src/Socialize.Api/Infrastructure/Development/DevelopmentSeedExtensions.cs b/backend/src/Socialize.Api/Infrastructure/Development/DevelopmentSeedExtensions.cs index 09a3321..f53441f 100644 --- a/backend/src/Socialize.Api/Infrastructure/Development/DevelopmentSeedExtensions.cs +++ b/backend/src/Socialize.Api/Infrastructure/Development/DevelopmentSeedExtensions.cs @@ -122,6 +122,7 @@ public static class DevelopmentSeedExtensions await EnsureOrganizationDataAsync( manager.Id, + dev.Id, dbContext, cancellationToken); @@ -234,6 +235,7 @@ public static class DevelopmentSeedExtensions private static async Task EnsureOrganizationDataAsync( Guid managerUserId, + Guid developerUserId, AppDbContext dbContext, CancellationToken cancellationToken) { @@ -255,25 +257,51 @@ public static class DevelopmentSeedExtensions organization.Slug = "northstar-collective"; organization.OwnerUserId = managerUserId; + await UpsertOrganizationMembershipAsync( + dbContext, + Guid.Parse("99999999-9999-9999-9999-000000000001"), + OrganizationId, + managerUserId, + OrganizationRoles.Owner, + cancellationToken); + + await UpsertOrganizationMembershipAsync( + dbContext, + Guid.Parse("99999999-9999-9999-9999-000000000002"), + OrganizationId, + developerUserId, + OrganizationRoles.Admin, + cancellationToken); + + await dbContext.SaveChangesAsync(cancellationToken); + } + + private static async Task UpsertOrganizationMembershipAsync( + AppDbContext dbContext, + Guid membershipId, + Guid organizationId, + Guid userId, + string role, + CancellationToken cancellationToken) + { OrganizationMembership? membership = await dbContext.OrganizationMemberships .SingleOrDefaultAsync( - candidate => candidate.OrganizationId == OrganizationId && candidate.UserId == managerUserId, + candidate => candidate.OrganizationId == organizationId && candidate.UserId == userId, cancellationToken); if (membership is null) { membership = new OrganizationMembership { - Id = Guid.Parse("99999999-9999-9999-9999-000000000001"), - OrganizationId = OrganizationId, - UserId = managerUserId, - Role = OrganizationRoles.Owner, + Id = membershipId, + OrganizationId = organizationId, + UserId = userId, + Role = role, CreatedAt = DateTimeOffset.UtcNow, }; dbContext.OrganizationMemberships.Add(membership); } - membership.Role = OrganizationRoles.Owner; - await dbContext.SaveChangesAsync(cancellationToken); + membership.Role = role; } private static async Task EnsureWorkspaceDataAsync( diff --git a/frontend/src/features/organizations/stores/organizationStore.js b/frontend/src/features/organizations/stores/organizationStore.js new file mode 100644 index 0000000..201f38c --- /dev/null +++ b/frontend/src/features/organizations/stores/organizationStore.js @@ -0,0 +1,142 @@ +import { computed, ref, watch } from 'vue'; +import { defineStore } from 'pinia'; +import { useAuthStore } from '@/features/auth/stores/authStore.js'; +import { useClient } from '@/plugins/api.js'; + +export const organizationPermissions = { + manageOrganizationSettings: 'ManageOrganizationSettings', + manageOrganizationMembers: 'ManageOrganizationMembers', + createWorkspaces: 'CreateWorkspaces', + manageWorkspaces: 'ManageWorkspaces', + manageBilling: 'ManageBilling', + manageConnectors: 'ManageConnectors', + accessOwnedWorkspaces: 'AccessOwnedWorkspaces', +}; + +export const useOrganizationStore = defineStore('organization', () => { + const authStore = useAuthStore(); + const client = useClient(); + + const organizations = ref([]); + const selectedOrganizationId = ref(null); + const detailsById = ref({}); + const isLoading = ref(false); + const isLoadingDetails = ref(false); + const error = ref(null); + + const activeOrganization = computed(() => + organizations.value.find(organization => organization.id === selectedOrganizationId.value) ?? null + ); + + function userCan(organization, permission) { + return Boolean(organization?.currentUserPermissions?.includes(permission)); + } + + function setSelectedOrganization(organizationId) { + if (organizations.value.some(organization => organization.id === organizationId)) { + selectedOrganizationId.value = organizationId; + } + } + + function setSelectedOrganizationFromWorkspace(workspace) { + if (workspace?.organizationId) { + if ( + organizations.value.length === 0 || + organizations.value.some(organization => organization.id === workspace.organizationId) + ) { + selectedOrganizationId.value = workspace.organizationId; + } + } + } + + async function fetchOrganizations() { + if (!authStore.isAuthenticated) { + organizations.value = []; + selectedOrganizationId.value = null; + detailsById.value = {}; + error.value = null; + return []; + } + + isLoading.value = true; + error.value = null; + + try { + const response = await client.get('/api/organizations'); + organizations.value = response.data ?? []; + + if (!organizations.value.some(organization => organization.id === selectedOrganizationId.value)) { + selectedOrganizationId.value = organizations.value[0]?.id ?? null; + } + + return organizations.value; + } catch (fetchError) { + console.error('Failed to fetch organizations:', fetchError); + organizations.value = []; + selectedOrganizationId.value = null; + error.value = 'Failed to load organizations.'; + return []; + } finally { + isLoading.value = false; + } + } + + async function fetchOrganization(organizationId) { + if (!authStore.isAuthenticated || !organizationId) { + return null; + } + + isLoadingDetails.value = true; + error.value = null; + + try { + const response = await client.get(`/api/organizations/${organizationId}`); + if (response.data) { + detailsById.value = { + ...detailsById.value, + [organizationId]: response.data, + }; + selectedOrganizationId.value = organizationId; + } + + return response.data ?? null; + } catch (fetchError) { + console.error('Failed to fetch organization:', fetchError); + error.value = 'Failed to load organization.'; + return null; + } finally { + isLoadingDetails.value = false; + } + } + + watch( + () => authStore.isAuthenticated, + async isAuthenticated => { + if (!isAuthenticated) { + organizations.value = []; + selectedOrganizationId.value = null; + detailsById.value = {}; + error.value = null; + return; + } + + await fetchOrganizations(); + }, + { immediate: true } + ); + + return { + organizations, + selectedOrganizationId, + activeOrganization, + detailsById, + isLoading, + isLoadingDetails, + error, + userCan, + setSelectedOrganization, + setSelectedOrganizationFromWorkspace, + fetchOrganizations, + fetchOrganization, + }; +}); diff --git a/frontend/src/features/organizations/views/OrganizationSettingsView.vue b/frontend/src/features/organizations/views/OrganizationSettingsView.vue new file mode 100644 index 0000000..45bea32 --- /dev/null +++ b/frontend/src/features/organizations/views/OrganizationSettingsView.vue @@ -0,0 +1,365 @@ + + + + + diff --git a/frontend/src/features/workspaces/stores/workspaceStore.js b/frontend/src/features/workspaces/stores/workspaceStore.js index 7ab9e91..654ad29 100644 --- a/frontend/src/features/workspaces/stores/workspaceStore.js +++ b/frontend/src/features/workspaces/stores/workspaceStore.js @@ -1,10 +1,12 @@ import { computed, ref, watch } from 'vue'; import { defineStore } from 'pinia'; import { useAuthStore } from '@/features/auth/stores/authStore.js'; +import { useOrganizationStore } from '@/features/organizations/stores/organizationStore.js'; import { useClient } from '@/plugins/api.js'; export const useWorkspaceStore = defineStore('workspace', () => { const authStore = useAuthStore(); + const organizationStore = useOrganizationStore(); const client = useClient(); const workspaces = ref([]); @@ -42,6 +44,8 @@ export const useWorkspaceStore = defineStore('workspace', () => { if (!workspaces.value.some(workspace => workspace.id === activeWorkspaceId.value)) { activeWorkspaceId.value = workspaces.value[0]?.id ?? null; } + + organizationStore.setSelectedOrganizationFromWorkspace(activeWorkspace.value); } catch (fetchError) { console.error('Failed to fetch workspaces:', fetchError); workspaces.value = []; @@ -161,8 +165,14 @@ export const useWorkspaceStore = defineStore('workspace', () => { } function setActiveWorkspace(workspaceId) { + if (!workspaceId) { + activeWorkspaceId.value = null; + return; + } + if (workspaces.value.some(workspace => workspace.id === workspaceId)) { activeWorkspaceId.value = workspaceId; + organizationStore.setSelectedOrganizationFromWorkspace(activeWorkspace.value); } } diff --git a/frontend/src/features/workspaces/views/WorkspaceCreateView.vue b/frontend/src/features/workspaces/views/WorkspaceCreateView.vue index ed311f5..6f3c17d 100644 --- a/frontend/src/features/workspaces/views/WorkspaceCreateView.vue +++ b/frontend/src/features/workspaces/views/WorkspaceCreateView.vue @@ -2,11 +2,13 @@ import { computed, reactive, ref } from 'vue'; import { useRouter } from 'vue-router'; import { useI18n } from 'vue-i18n'; + import { useOrganizationStore } from '@/features/organizations/stores/organizationStore.js'; import TimeZoneSelect from '@/features/workspaces/components/TimeZoneSelect.vue'; import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js'; const router = useRouter(); const { t } = useI18n(); + const organizationStore = useOrganizationStore(); const workspaceStore = useWorkspaceStore(); const form = reactive({ @@ -23,6 +25,10 @@ return slugify(form.name); }); + const selectedOrganizationId = computed({ + get: () => organizationStore.selectedOrganizationId, + set: value => organizationStore.setSelectedOrganization(value), + }); function computedDefaultTimeZone() { return workspaceStore.activeWorkspace?.timeZone || 'America/Montreal'; @@ -48,13 +54,14 @@ const slug = slugify(form.slug || form.name); const timeZone = form.timeZone.trim(); - if (!name || !slug || !timeZone) { + if (!name || !slug || !timeZone || !selectedOrganizationId.value) { formError.value = t('workspaceCreate.errors.required'); return; } try { await workspaceStore.createWorkspace({ + organizationId: selectedOrganizationId.value, name, slug, timeZone, @@ -114,6 +121,22 @@ /> + +