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

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