feat: add organization onboarding
This commit is contained in:
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
35
docs/TASKS/organizations/007-organization-onboarding.md
Normal file
35
docs/TASKS/organizations/007-organization-onboarding.md
Normal 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.
|
||||||
129
frontend/src/api/schema.d.ts
vendored
129
frontend/src/api/schema.d.ts
vendored
@@ -132,22 +132,6 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/api/organizations": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -157,6 +141,22 @@ export interface paths {
|
|||||||
};
|
};
|
||||||
get: operations["SocializeApiModulesOrganizationsHandlersGetOrganizationsHandler"];
|
get: operations["SocializeApiModulesOrganizationsHandlersGetOrganizationsHandler"];
|
||||||
put?: never;
|
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;
|
post?: never;
|
||||||
delete?: never;
|
delete?: never;
|
||||||
options?: never;
|
options?: never;
|
||||||
@@ -1243,6 +1243,9 @@ export interface components {
|
|||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
limit?: number | null;
|
limit?: number | null;
|
||||||
};
|
};
|
||||||
|
SocializeApiModulesOrganizationsHandlersCreateOrganizationRequest: {
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
SocializeApiModulesOrganizationsHandlersUpdateOrganizationRequest: {
|
SocializeApiModulesOrganizationsHandlersUpdateOrganizationRequest: {
|
||||||
name: string;
|
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: {
|
SocializeApiModulesOrganizationsHandlersGetOrganizationHandler: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
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: {
|
SocializeApiModulesNotificationsHandlersGetNotificationsHandler: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export const useOrganizationStore = defineStore('organization', () => {
|
|||||||
const detailsById = ref({});
|
const detailsById = ref({});
|
||||||
const isLoading = ref(false);
|
const isLoading = ref(false);
|
||||||
const isLoadingDetails = ref(false);
|
const isLoadingDetails = ref(false);
|
||||||
|
const isCreating = ref(false);
|
||||||
const isSaving = ref(false);
|
const isSaving = ref(false);
|
||||||
const isAddingMember = ref(false);
|
const isAddingMember = ref(false);
|
||||||
const isUploadingLogo = 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) {
|
async function updateOrganization(organizationId, payload) {
|
||||||
if (!authStore.isAuthenticated || !organizationId) {
|
if (!authStore.isAuthenticated || !organizationId) {
|
||||||
throw new Error('You must be authenticated to update an organization.');
|
throw new Error('You must be authenticated to update an organization.');
|
||||||
@@ -263,6 +296,7 @@ export const useOrganizationStore = defineStore('organization', () => {
|
|||||||
detailsById,
|
detailsById,
|
||||||
isLoading,
|
isLoading,
|
||||||
isLoadingDetails,
|
isLoadingDetails,
|
||||||
|
isCreating,
|
||||||
isSaving,
|
isSaving,
|
||||||
isAddingMember,
|
isAddingMember,
|
||||||
isUploadingLogo,
|
isUploadingLogo,
|
||||||
@@ -272,6 +306,7 @@ export const useOrganizationStore = defineStore('organization', () => {
|
|||||||
setSelectedOrganizationFromWorkspace,
|
setSelectedOrganizationFromWorkspace,
|
||||||
fetchOrganizations,
|
fetchOrganizations,
|
||||||
fetchOrganization,
|
fetchOrganization,
|
||||||
|
createOrganization,
|
||||||
updateOrganization,
|
updateOrganization,
|
||||||
addMember,
|
addMember,
|
||||||
uploadLogo,
|
uploadLogo,
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -389,6 +389,46 @@
|
|||||||
"createFailed": "The workspace could not be created."
|
"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": {
|
"organizationSettings": {
|
||||||
"eyebrow": "Organization",
|
"eyebrow": "Organization",
|
||||||
"title": "Organization settings",
|
"title": "Organization settings",
|
||||||
|
|||||||
@@ -389,6 +389,46 @@
|
|||||||
"createFailed": "L'espace n'a pas pu etre cree."
|
"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": {
|
"organizationSettings": {
|
||||||
"eyebrow": "Organisation",
|
"eyebrow": "Organisation",
|
||||||
"title": "Parametres de l'organisation",
|
"title": "Parametres de l'organisation",
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { useAuthStore } from '@/features/auth/stores/authStore.js';
|
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';
|
import { createRouter, createWebHistory } from 'vue-router';
|
||||||
|
|
||||||
const LoginView = () => import('@/features/auth/views/LoginView.vue');
|
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 CampaignDetailView = () => import('@/features/campaigns/views/CampaignDetailView.vue');
|
||||||
const MediaLibraryView = () => import('@/features/content/views/MediaLibraryView.vue');
|
const MediaLibraryView = () => import('@/features/content/views/MediaLibraryView.vue');
|
||||||
const WorkspaceCreateView = () => import('@/features/workspaces/views/WorkspaceCreateView.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 OrganizationSettingsView = () => import('@/features/organizations/views/OrganizationSettingsView.vue');
|
||||||
const SettingsLayoutView = () => import('@/features/settings/views/SettingsLayoutView.vue');
|
const SettingsLayoutView = () => import('@/features/settings/views/SettingsLayoutView.vue');
|
||||||
const UserSettingsView = () => import('@/features/user-profile/views/UserSettingsView.vue');
|
const UserSettingsView = () => import('@/features/user-profile/views/UserSettingsView.vue');
|
||||||
@@ -132,6 +135,12 @@ const routes = [
|
|||||||
component: DeveloperFeedbackDetailView,
|
component: DeveloperFeedbackDetailView,
|
||||||
meta: { requiresAuth: true, roles: ['developer'] },
|
meta: { requiresAuth: true, roles: ['developer'] },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/app/onboarding',
|
||||||
|
name: 'organization-onboarding',
|
||||||
|
component: OrganizationOnboardingView,
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/app/organizations/:organizationId/settings',
|
path: '/app/organizations/:organizationId/settings',
|
||||||
name: 'organization-settings',
|
name: 'organization-settings',
|
||||||
@@ -148,7 +157,7 @@ const routes = [
|
|||||||
path: '/app/workspaces/new',
|
path: '/app/workspaces/new',
|
||||||
name: 'workspace-create',
|
name: 'workspace-create',
|
||||||
component: WorkspaceCreateView,
|
component: WorkspaceCreateView,
|
||||||
meta: { requiresAuth: true, roles: ['administrator', 'manager'] },
|
meta: { requiresAuth: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/app/settings',
|
path: '/app/settings',
|
||||||
@@ -243,8 +252,33 @@ const router = createRouter({
|
|||||||
routes,
|
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
|
// Navigation guards
|
||||||
router.beforeEach((to) => {
|
router.beforeEach(async (to) => {
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
if (to.matched.some(record => record.meta.requiresAuth)) {
|
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 ?? []);
|
const requiredRoles = to.matched.flatMap(record => record.meta.roles ?? []);
|
||||||
if (requiredRoles.length > 0 && !authStore.hasAnyRole(requiredRoles)) {
|
if (requiredRoles.length > 0 && !authStore.hasAnyRole(requiredRoles)) {
|
||||||
return { name: 'dashboard' };
|
return { name: 'dashboard' };
|
||||||
@@ -264,9 +304,7 @@ router.beforeEach((to) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (to.matched.some(record => record.meta.notAuthenticated)) {
|
if (to.matched.some(record => record.meta.notAuthenticated)) {
|
||||||
return authStore.isAuthenticated
|
return authStore.isAuthenticated ? await getAuthenticatedHomeRoute() : true;
|
||||||
? { name: 'dashboard' }
|
|
||||||
: true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -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}": {
|
"/api/organizations/{organizationId}": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"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": {
|
"/api/notifications": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -4220,6 +4270,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"SocializeApiModulesOrganizationsHandlersCreateOrganizationRequest": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"name"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 256,
|
||||||
|
"minLength": 0,
|
||||||
|
"nullable": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"SocializeApiModulesOrganizationsHandlersUpdateOrganizationRequest": {
|
"SocializeApiModulesOrganizationsHandlersUpdateOrganizationRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
|
|||||||
Reference in New Issue
Block a user