feat: add organization onboarding
All checks were successful
deploy-socialize / image (push) Successful in 1m8s
deploy-socialize / deploy (push) Successful in 19s

This commit is contained in:
2026-05-07 20:07:50 -04:00
parent 4aaa1a7f90
commit db16e79d9f
9 changed files with 699 additions and 80 deletions

View File

@@ -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<CreateOrganizationRequest>
{
public CreateOrganizationRequestValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
}
}
internal class CreateOrganizationHandler(
AppDbContext dbContext)
: Endpoint<CreateOrganizationRequest, OrganizationDto>
{
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);
}
}

View File

@@ -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.

View File

@@ -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?: {

View File

@@ -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,

View File

@@ -0,0 +1,259 @@
<script setup>
import { computed, reactive, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { mdiAccountArrowRightOutline, mdiDomainPlus, mdiEmailOutline } from '@mdi/js';
import { useOrganizationStore } from '@/features/organizations/stores/organizationStore.js';
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
const { t } = useI18n();
const router = useRouter();
const organizationStore = useOrganizationStore();
const workspaceStore = useWorkspaceStore();
const createForm = reactive({
name: '',
});
const accessForm = reactive({
targetType: 'organization',
targetName: '',
adminEmail: '',
note: '',
});
const createError = ref(null);
const requestStatus = ref(null);
const accessTypeOptions = computed(() => [
{ title: t('organizationOnboarding.request.types.organization'), value: 'organization' },
{ title: t('organizationOnboarding.request.types.workspace'), value: 'workspace' },
]);
async function createOrganization() {
if (organizationStore.isCreating) {
return;
}
createError.value = null;
const name = createForm.name.trim();
if (!name) {
createError.value = t('organizationOnboarding.create.errors.required');
return;
}
try {
await organizationStore.createOrganization({ name });
await Promise.all([
organizationStore.fetchOrganizations(),
workspaceStore.fetchWorkspaces(),
]);
await router.push({ name: 'workspace-create' });
} catch (error) {
createError.value = t('organizationOnboarding.create.errors.failed');
}
}
function requestAccess() {
requestStatus.value = null;
if (!accessForm.adminEmail.trim() || !accessForm.targetName.trim()) {
requestStatus.value = t('organizationOnboarding.request.errors.required');
return;
}
const subject = t('organizationOnboarding.request.emailSubject', {
target: accessForm.targetName.trim(),
});
const body = t('organizationOnboarding.request.emailBody', {
targetType: t(`organizationOnboarding.request.types.${accessForm.targetType}`),
target: accessForm.targetName.trim(),
note: accessForm.note.trim() || t('organizationOnboarding.request.noNote'),
});
window.location.href = `mailto:${encodeURIComponent(accessForm.adminEmail.trim())}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
requestStatus.value = t('organizationOnboarding.request.sent');
}
</script>
<template>
<section class="onboarding-page">
<header class="hero">
<div>
<div class="eyebrow">{{ t('organizationOnboarding.eyebrow') }}</div>
<h1>{{ t('organizationOnboarding.title') }}</h1>
<p>{{ t('organizationOnboarding.description') }}</p>
</div>
</header>
<div class="onboarding-grid">
<article class="panel">
<div class="panel-header">
<v-icon :icon="mdiDomainPlus" />
<div>
<strong>{{ t('organizationOnboarding.create.title') }}</strong>
<span>{{ t('organizationOnboarding.create.description') }}</span>
</div>
</div>
<div
v-if="createError"
class="page-message error"
>
{{ createError }}
</div>
<v-form
class="form-stack"
@submit.prevent="createOrganization"
>
<v-text-field
v-model="createForm.name"
:label="t('organizationOnboarding.create.fields.name')"
:placeholder="t('organizationOnboarding.create.fields.namePlaceholder')"
:disabled="organizationStore.isCreating"
variant="outlined"
hide-details
/>
<v-btn
:loading="organizationStore.isCreating"
color="primary"
type="submit"
>
{{ t('organizationOnboarding.create.action') }}
</v-btn>
</v-form>
</article>
<article class="panel">
<div class="panel-header">
<v-icon :icon="mdiAccountArrowRightOutline" />
<div>
<strong>{{ t('organizationOnboarding.request.title') }}</strong>
<span>{{ t('organizationOnboarding.request.description') }}</span>
</div>
</div>
<div
v-if="requestStatus"
class="page-message"
>
{{ requestStatus }}
</div>
<v-form
class="form-stack"
@submit.prevent="requestAccess"
>
<v-select
v-model="accessForm.targetType"
:items="accessTypeOptions"
:label="t('organizationOnboarding.request.fields.type')"
variant="outlined"
hide-details
/>
<v-text-field
v-model="accessForm.targetName"
:label="t('organizationOnboarding.request.fields.name')"
variant="outlined"
hide-details
/>
<v-text-field
v-model="accessForm.adminEmail"
:label="t('organizationOnboarding.request.fields.adminEmail')"
type="email"
variant="outlined"
hide-details
/>
<v-textarea
v-model="accessForm.note"
:label="t('organizationOnboarding.request.fields.note')"
rows="3"
variant="outlined"
hide-details
/>
<v-btn
color="secondary"
type="submit"
:prepend-icon="mdiEmailOutline"
>
{{ t('organizationOnboarding.request.action') }}
</v-btn>
</v-form>
</article>
</div>
</section>
</template>
<style scoped>
@reference "@/assets/main.css";
.onboarding-page {
@apply mx-auto flex w-full max-w-6xl flex-col gap-6 px-5 py-8 md:px-8;
}
.hero,
.panel {
@apply rounded-lg border;
border-color: rgba(23, 32, 51, 0.08);
background: rgba(255, 255, 255, 0.92);
}
.hero {
@apply p-6 md:p-8;
}
.eyebrow {
@apply text-xs font-bold uppercase tracking-[0.22em];
color: #0f766e;
}
.hero h1 {
@apply mt-2 text-3xl font-black md:text-4xl;
color: #172033;
}
.hero p,
.panel-header span {
@apply mt-2 text-sm leading-6;
color: #526178;
}
.onboarding-grid {
@apply grid gap-4 lg:grid-cols-2;
}
.panel {
@apply flex flex-col gap-4 p-5;
}
.panel-header {
@apply flex gap-3;
}
.panel-header .v-icon {
@apply mt-1;
color: #0f766e;
}
.panel-header strong {
@apply block text-xl font-black;
color: #172033;
}
.form-stack {
@apply flex flex-col gap-3;
}
.page-message {
@apply rounded-lg border p-3 text-sm font-semibold;
border-color: rgba(23, 32, 51, 0.08);
background: rgba(23, 32, 51, 0.04);
color: #526178;
}
.page-message.error {
border-color: rgba(220, 38, 38, 0.24);
color: #b91c1c;
}
</style>

View File

@@ -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",

View File

@@ -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",

View File

@@ -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;

View File

@@ -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,