diff --git a/backend/src/Socialize.Api/Modules/Organizations/Handlers/CreateOrganization.cs b/backend/src/Socialize.Api/Modules/Organizations/Handlers/CreateOrganization.cs new file mode 100644 index 00000000..c6a6ac4a --- /dev/null +++ b/backend/src/Socialize.Api/Modules/Organizations/Handlers/CreateOrganization.cs @@ -0,0 +1,64 @@ +using FastEndpoints; +using Socialize.Api.Data; +using Socialize.Api.Infrastructure.Security; +using Socialize.Api.Modules.Organizations.Data; +using Socialize.Api.Modules.Organizations.Services; + +namespace Socialize.Api.Modules.Organizations.Handlers; + +internal record CreateOrganizationRequest( + string Name); + +internal class CreateOrganizationRequestValidator + : Validator +{ + public CreateOrganizationRequestValidator() + { + RuleFor(x => x.Name).NotEmpty().MaximumLength(256); + } +} + +internal class CreateOrganizationHandler( + AppDbContext dbContext) + : Endpoint +{ + public override void Configure() + { + Post("/api/organizations"); + Options(o => o.WithTags("Organizations")); + } + + public override async Task HandleAsync(CreateOrganizationRequest request, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(request); + + Guid userId = User.GetUserId(); + Organization organization = new() + { + Id = Guid.NewGuid(), + Name = request.Name.Trim(), + OwnerUserId = userId, + CreatedAt = DateTimeOffset.UtcNow, + }; + + OrganizationMembership ownerMembership = new() + { + Id = Guid.NewGuid(), + OrganizationId = organization.Id, + UserId = userId, + Role = OrganizationRoles.Owner, + CreatedAt = DateTimeOffset.UtcNow, + }; + + dbContext.Organizations.Add(organization); + dbContext.OrganizationMemberships.Add(ownerMembership); + await dbContext.SaveChangesAsync(ct); + + await SendAsync( + OrganizationDto.FromOrganization( + organization, + OrganizationPermissionRules.GetPermissionsForRole(OrganizationRoles.Owner)), + StatusCodes.Status201Created, + ct); + } +} diff --git a/docs/TASKS/organizations/007-organization-onboarding.md b/docs/TASKS/organizations/007-organization-onboarding.md new file mode 100644 index 00000000..9eb1df91 --- /dev/null +++ b/docs/TASKS/organizations/007-organization-onboarding.md @@ -0,0 +1,35 @@ +# Task: Organization onboarding for users without access + +## Feature + +`docs/FEATURES/organizations.md` + +## Goal + +When an authenticated user has no accessible organizations and no accessible workspaces, redirect them to an onboarding screen where they can create a new organization as its owner or request access from an existing organization/workspace administrator. + +## Scope + +- Add a protected onboarding route. +- Add a backend endpoint for creating an organization owned by the current user. +- Add frontend store support for organization creation. +- Redirect authenticated users with no organization/workspace access to onboarding. +- Keep users with direct workspace access out of onboarding even if they are not organization members. +- Add English and French UI strings. + +## Validation + +```bash +dotnet build backend/Socialize.slnx +cd frontend +npm run build +``` + +## Done + +- [x] Users without organization or workspace access are redirected to onboarding. +- [x] Users can create an organization as owner. +- [x] Users can prepare an organization/workspace access request email. +- [x] OpenAPI snapshot and frontend schema are updated. +- [x] Backend build and tests pass. +- [x] Frontend build passes. diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index 1a0f0478..38ce227a 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -132,22 +132,6 @@ export interface paths { patch?: never; trace?: never; }; - "/api/organizations/{organizationId}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: operations["SocializeApiModulesOrganizationsHandlersGetOrganizationHandler"]; - put: operations["SocializeApiModulesOrganizationsHandlersUpdateOrganizationHandler"]; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/api/organizations": { parameters: { query?: never; @@ -157,6 +141,22 @@ export interface paths { }; get: operations["SocializeApiModulesOrganizationsHandlersGetOrganizationsHandler"]; put?: never; + post: operations["SocializeApiModulesOrganizationsHandlersCreateOrganizationHandler"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/organizations/{organizationId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["SocializeApiModulesOrganizationsHandlersGetOrganizationHandler"]; + put: operations["SocializeApiModulesOrganizationsHandlersUpdateOrganizationHandler"]; post?: never; delete?: never; options?: never; @@ -1243,6 +1243,9 @@ export interface components { /** Format: int32 */ limit?: number | null; }; + SocializeApiModulesOrganizationsHandlersCreateOrganizationRequest: { + name: string; + }; SocializeApiModulesOrganizationsHandlersUpdateOrganizationRequest: { name: string; }; @@ -2297,6 +2300,73 @@ export interface operations { }; }; }; + SocializeApiModulesOrganizationsHandlersGetOrganizationsHandler: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SocializeApiModulesOrganizationsHandlersOrganizationDto"][]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + SocializeApiModulesOrganizationsHandlersCreateOrganizationHandler: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SocializeApiModulesOrganizationsHandlersCreateOrganizationRequest"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SocializeApiModulesOrganizationsHandlersOrganizationDto"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["FastEndpointsErrorResponse"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; SocializeApiModulesOrganizationsHandlersGetOrganizationHandler: { parameters: { query?: never; @@ -2368,33 +2438,6 @@ export interface operations { }; }; }; - SocializeApiModulesOrganizationsHandlersGetOrganizationsHandler: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Success */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["SocializeApiModulesOrganizationsHandlersOrganizationDto"][]; - }; - }; - /** @description Unauthorized */ - 401: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; SocializeApiModulesNotificationsHandlersGetNotificationsHandler: { parameters: { query?: { diff --git a/frontend/src/features/organizations/stores/organizationStore.js b/frontend/src/features/organizations/stores/organizationStore.js index 8ffb738e..b4a769aa 100644 --- a/frontend/src/features/organizations/stores/organizationStore.js +++ b/frontend/src/features/organizations/stores/organizationStore.js @@ -22,6 +22,7 @@ export const useOrganizationStore = defineStore('organization', () => { const detailsById = ref({}); const isLoading = ref(false); const isLoadingDetails = ref(false); + const isCreating = ref(false); const isSaving = ref(false); const isAddingMember = ref(false); const isUploadingLogo = ref(false); @@ -117,6 +118,38 @@ export const useOrganizationStore = defineStore('organization', () => { } } + async function createOrganization(payload) { + if (!authStore.isAuthenticated) { + throw new Error('You must be authenticated to create an organization.'); + } + + if (isCreating.value) { + throw new Error('An organization creation request is already in progress.'); + } + + isCreating.value = true; + error.value = null; + + try { + const response = await client.post('/api/organizations', payload); + const organization = response.data; + + if (organization) { + organizations.value = [...organizations.value, organization] + .sort((left, right) => left.name.localeCompare(right.name)); + selectedOrganizationId.value = organization.id; + } + + return organization; + } catch (createError) { + console.error('Failed to create organization:', createError); + error.value = 'Failed to create organization.'; + throw createError; + } finally { + isCreating.value = false; + } + } + async function updateOrganization(organizationId, payload) { if (!authStore.isAuthenticated || !organizationId) { throw new Error('You must be authenticated to update an organization.'); @@ -263,6 +296,7 @@ export const useOrganizationStore = defineStore('organization', () => { detailsById, isLoading, isLoadingDetails, + isCreating, isSaving, isAddingMember, isUploadingLogo, @@ -272,6 +306,7 @@ export const useOrganizationStore = defineStore('organization', () => { setSelectedOrganizationFromWorkspace, fetchOrganizations, fetchOrganization, + createOrganization, updateOrganization, addMember, uploadLogo, diff --git a/frontend/src/features/organizations/views/OrganizationOnboardingView.vue b/frontend/src/features/organizations/views/OrganizationOnboardingView.vue new file mode 100644 index 00000000..514b29dd --- /dev/null +++ b/frontend/src/features/organizations/views/OrganizationOnboardingView.vue @@ -0,0 +1,259 @@ + + + + + diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 87059d33..550aae8f 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -389,6 +389,46 @@ "createFailed": "The workspace could not be created." } }, + "organizationOnboarding": { + "eyebrow": "Account setup", + "title": "Create an organization or request access", + "description": "Your account does not have access to an organization or workspace yet. Start a new organization if you own the account, or ask an administrator to invite you.", + "create": { + "title": "Create a new organization", + "description": "You will become the owner and can create the first workspace next.", + "action": "Create organization", + "fields": { + "name": "Organization name", + "namePlaceholder": "Northstar Agency" + }, + "errors": { + "required": "Organization name is required.", + "failed": "The organization could not be created." + } + }, + "request": { + "title": "Request access", + "description": "Send an access request to the administrator who owns the organization or workspace.", + "action": "Prepare request email", + "sent": "Your email app should open with the request prepared.", + "noNote": "No additional note provided.", + "emailSubject": "Access request for {target}", + "emailBody": "Hello,\n\nPlease invite me to this {targetType}: {target}.\n\nNote: {note}\n\nThank you.", + "types": { + "organization": "organization", + "workspace": "workspace" + }, + "fields": { + "type": "Access type", + "name": "Organization or workspace name", + "adminEmail": "Administrator email", + "note": "Note" + }, + "errors": { + "required": "Administrator email and organization or workspace name are required." + } + } + }, "organizationSettings": { "eyebrow": "Organization", "title": "Organization settings", diff --git a/frontend/src/locales/fr.json b/frontend/src/locales/fr.json index 2d3c0502..ec61d82a 100644 --- a/frontend/src/locales/fr.json +++ b/frontend/src/locales/fr.json @@ -389,6 +389,46 @@ "createFailed": "L'espace n'a pas pu etre cree." } }, + "organizationOnboarding": { + "eyebrow": "Configuration du compte", + "title": "Creer une organisation ou demander un acces", + "description": "Votre compte n'a pas encore acces a une organisation ou a un espace. Creez une organisation si vous possedez le compte, ou demandez a un administrateur de vous inviter.", + "create": { + "title": "Creer une nouvelle organisation", + "description": "Vous en deviendrez le proprietaire et pourrez creer le premier espace ensuite.", + "action": "Creer l'organisation", + "fields": { + "name": "Nom de l'organisation", + "namePlaceholder": "Agence Northstar" + }, + "errors": { + "required": "Le nom de l'organisation est requis.", + "failed": "L'organisation n'a pas pu etre creee." + } + }, + "request": { + "title": "Demander un acces", + "description": "Envoyez une demande a l'administrateur qui gere l'organisation ou l'espace.", + "action": "Preparer l'email", + "sent": "Votre application email devrait s'ouvrir avec la demande preparee.", + "noNote": "Aucune note supplementaire.", + "emailSubject": "Demande d'acces pour {target}", + "emailBody": "Bonjour,\n\nVeuillez m'inviter a ce/cette {targetType} : {target}.\n\nNote : {note}\n\nMerci.", + "types": { + "organization": "organisation", + "workspace": "espace" + }, + "fields": { + "type": "Type d'acces", + "name": "Nom de l'organisation ou de l'espace", + "adminEmail": "Email de l'administrateur", + "note": "Note" + }, + "errors": { + "required": "L'email de l'administrateur et le nom de l'organisation ou de l'espace sont requis." + } + } + }, "organizationSettings": { "eyebrow": "Organisation", "title": "Parametres de l'organisation", diff --git a/frontend/src/router/router.js b/frontend/src/router/router.js index 520a1ce0..4dc57f89 100644 --- a/frontend/src/router/router.js +++ b/frontend/src/router/router.js @@ -1,4 +1,6 @@ import { useAuthStore } from '@/features/auth/stores/authStore.js'; +import { useOrganizationStore } from '@/features/organizations/stores/organizationStore.js'; +import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js'; import { createRouter, createWebHistory } from 'vue-router'; const LoginView = () => import('@/features/auth/views/LoginView.vue'); @@ -18,6 +20,7 @@ const CampaignsView = () => import('@/features/campaigns/views/CampaignsView.vue const CampaignDetailView = () => import('@/features/campaigns/views/CampaignDetailView.vue'); const MediaLibraryView = () => import('@/features/content/views/MediaLibraryView.vue'); const WorkspaceCreateView = () => import('@/features/workspaces/views/WorkspaceCreateView.vue'); +const OrganizationOnboardingView = () => import('@/features/organizations/views/OrganizationOnboardingView.vue'); const OrganizationSettingsView = () => import('@/features/organizations/views/OrganizationSettingsView.vue'); const SettingsLayoutView = () => import('@/features/settings/views/SettingsLayoutView.vue'); const UserSettingsView = () => import('@/features/user-profile/views/UserSettingsView.vue'); @@ -132,6 +135,12 @@ const routes = [ component: DeveloperFeedbackDetailView, meta: { requiresAuth: true, roles: ['developer'] }, }, + { + path: '/app/onboarding', + name: 'organization-onboarding', + component: OrganizationOnboardingView, + meta: { requiresAuth: true }, + }, { path: '/app/organizations/:organizationId/settings', name: 'organization-settings', @@ -148,7 +157,7 @@ const routes = [ path: '/app/workspaces/new', name: 'workspace-create', component: WorkspaceCreateView, - meta: { requiresAuth: true, roles: ['administrator', 'manager'] }, + meta: { requiresAuth: true }, }, { path: '/app/settings', @@ -243,8 +252,33 @@ const router = createRouter({ routes, }); +async function loadAccessScope() { + const organizationStore = useOrganizationStore(); + const workspaceStore = useWorkspaceStore(); + + await Promise.all([ + organizationStore.fetchOrganizations(), + workspaceStore.fetchWorkspaces(), + ]); +} + +function hasNoOrganizationOrWorkspaceAccess() { + const organizationStore = useOrganizationStore(); + const workspaceStore = useWorkspaceStore(); + + return organizationStore.organizations.length === 0 && workspaceStore.workspaces.length === 0; +} + +async function getAuthenticatedHomeRoute() { + await loadAccessScope(); + + return hasNoOrganizationOrWorkspaceAccess() + ? { name: 'organization-onboarding' } + : { name: 'dashboard' }; +} + // Navigation guards -router.beforeEach((to) => { +router.beforeEach(async (to) => { const authStore = useAuthStore(); if (to.matched.some(record => record.meta.requiresAuth)) { @@ -255,6 +289,12 @@ router.beforeEach((to) => { }; } + await loadAccessScope(); + + if (hasNoOrganizationOrWorkspaceAccess() && to.name !== 'organization-onboarding') { + return { name: 'organization-onboarding' }; + } + const requiredRoles = to.matched.flatMap(record => record.meta.roles ?? []); if (requiredRoles.length > 0 && !authStore.hasAnyRole(requiredRoles)) { return { name: 'dashboard' }; @@ -264,9 +304,7 @@ router.beforeEach((to) => { } if (to.matched.some(record => record.meta.notAuthenticated)) { - return authStore.isAuthenticated - ? { name: 'dashboard' } - : true; + return authStore.isAuthenticated ? await getAuthenticatedHomeRoute() : true; } return true; diff --git a/shared/openapi/openapi.json b/shared/openapi/openapi.json index e8ea5a88..850b0f6b 100644 --- a/shared/openapi/openapi.json +++ b/shared/openapi/openapi.json @@ -511,6 +511,88 @@ ] } }, + "/api/organizations": { + "post": { + "tags": [ + "Organizations", + "Api" + ], + "operationId": "SocializeApiModulesOrganizationsHandlersCreateOrganizationHandler", + "requestBody": { + "x-name": "CreateOrganizationRequest", + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SocializeApiModulesOrganizationsHandlersCreateOrganizationRequest" + } + } + }, + "required": true, + "x-position": 1 + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SocializeApiModulesOrganizationsHandlersOrganizationDto" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/FastEndpointsErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "JWTBearerAuth": [] + } + ] + }, + "get": { + "tags": [ + "Organizations", + "Api" + ], + "operationId": "SocializeApiModulesOrganizationsHandlersGetOrganizationsHandler", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SocializeApiModulesOrganizationsHandlersOrganizationDto" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "JWTBearerAuth": [] + } + ] + } + }, "/api/organizations/{organizationId}": { "get": { "tags": [ @@ -612,38 +694,6 @@ ] } }, - "/api/organizations": { - "get": { - "tags": [ - "Organizations", - "Api" - ], - "operationId": "SocializeApiModulesOrganizationsHandlersGetOrganizationsHandler", - "responses": { - "200": { - "description": "Success", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SocializeApiModulesOrganizationsHandlersOrganizationDto" - } - } - } - } - }, - "401": { - "description": "Unauthorized" - } - }, - "security": [ - { - "JWTBearerAuth": [] - } - ] - } - }, "/api/notifications": { "get": { "tags": [ @@ -4220,6 +4270,21 @@ } } }, + "SocializeApiModulesOrganizationsHandlersCreateOrganizationRequest": { + "type": "object", + "additionalProperties": false, + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "maxLength": 256, + "minLength": 0, + "nullable": false + } + } + }, "SocializeApiModulesOrganizationsHandlersUpdateOrganizationRequest": { "type": "object", "additionalProperties": false,