From 2eeab139b5aac93619c8af21c04b5544f8a01264 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:32:42 +0700 Subject: [PATCH] feat(org): implement outbound organization creation with API delegation Add native in-app Create Organization modal in organization selector and delegate creation to api.dos.me via POST /user/organizations (Method 3: Delegated Creation). Co-authored-by: Cursor --- .../src/api/routes/users.controller.ts | 11 +++ .../layout/organization.selector.tsx | 67 ++++++++++++++++++- .../organizations/organization.repository.ts | 32 +++++++++ .../organizations/organization.service.ts | 41 ++++++++++++ .../organizations/create.organization.dto.ts | 8 +++ 5 files changed, 156 insertions(+), 3 deletions(-) create mode 100644 libraries/nestjs-libraries/src/dtos/organizations/create.organization.dto.ts diff --git a/apps/backend/src/api/routes/users.controller.ts b/apps/backend/src/api/routes/users.controller.ts index 896cc03b17..5ff100a353 100644 --- a/apps/backend/src/api/routes/users.controller.ts +++ b/apps/backend/src/api/routes/users.controller.ts @@ -23,6 +23,7 @@ import { getCookieUrlFromDomain } from '@gitroom/helpers/subdomain/subdomain.man import { pricing } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/pricing'; import { ApiTags } from '@nestjs/swagger'; import { UsersService } from '@gitroom/nestjs-libraries/database/prisma/users/users.service'; +import { CreateOrganizationDto } from '@gitroom/nestjs-libraries/dtos/organizations/create.organization.dto'; import { UserDetailDto } from '@gitroom/nestjs-libraries/dtos/users/user.details.dto'; import { EmailNotificationsDto } from '@gitroom/nestjs-libraries/dtos/users/email-notifications.dto'; import { HttpForbiddenException } from '@gitroom/nestjs-libraries/services/exception.filter'; @@ -304,6 +305,16 @@ export class UsersController { ); } + @Post('/organizations') + createOrg( + @GetUserFromRequest() user: User, + @Body() body: CreateOrganizationDto, + @Req() req: Request + ) { + const authHeader = (req.headers.authorization as string) || ''; + return this._orgService.createOrgForUser(user.id, body, authHeader); + } + @Post('/change-org') changeOrg( @Body('id') id: string, diff --git a/apps/frontend/src/components/layout/organization.selector.tsx b/apps/frontend/src/components/layout/organization.selector.tsx index 4f072f5938..2960172f82 100644 --- a/apps/frontend/src/components/layout/organization.selector.tsx +++ b/apps/frontend/src/components/layout/organization.selector.tsx @@ -1,13 +1,59 @@ 'use client'; -import React, { FC, useCallback, useMemo } from 'react'; +import React, { FC, useCallback, useMemo, useState } from 'react'; import { useFetch } from '@gitroom/helpers/utils/custom.fetch'; import useSWR from 'swr'; import { useUser } from '@gitroom/frontend/components/layout/user.context'; import clsx from 'clsx'; +import { useModals } from '@gitroom/frontend/components/layout/new-modal'; +import { Input } from '@gitroom/react/form/input'; +import { Button } from '@gitroom/react/form/button'; +import { useT } from '@gitroom/react/translation/get.transation.service.client'; + +export const CreateOrganization = () => { + const t = useT(); + const fetch = useFetch(); + const modals = useModals(); + const [name, setName] = useState(''); + const [loading, setLoading] = useState(false); + const create = useCallback(async () => { + setLoading(true); + const { id } = await ( + await fetch('/user/organizations', { + method: 'POST', + body: JSON.stringify({ name }), + }) + ).json(); + await fetch('/user/change-org', { + method: 'POST', + body: JSON.stringify({ id }), + }); + modals.closeAll(); + window.location.reload(); + }, [name]); + return ( +
+ setName(e.target.value)} + name="name" + label={t('organization_name', 'Organization name')} + placeholder={t('organization_name', 'Organization name')} + /> + +
+ ); +}; + export const OrganizationSelector: FC<{ asOpenSelect?: boolean }> = ({ asOpenSelect, }) => { + const t = useT(); + const modals = useModals(); const fetch = useFetch(); const user = useUser(); const load = useCallback(async () => { @@ -38,7 +84,17 @@ export const OrganizationSelector: FC<{ asOpenSelect?: boolean }> = ({ }, [] ); - if (isLoading || (!isLoading && data?.length === 1)) { + const createOrg = useCallback(() => { + modals.openModal({ + classNames: { + modal: 'bg-transparent text-textColor', + }, + title: t('create_new_organization', 'Create New Organization'), + withCloseButton: true, + children: , + }); + }, [t]); + if (isLoading) { return null; } return ( @@ -68,7 +124,7 @@ export const OrganizationSelector: FC<{ asOpenSelect?: boolean }> = ({ )} )} - {data?.length > 1 && ( + {(data?.length > 1 || !asOpenSelect) && (
= ({
) )} + {!asOpenSelect && ( +
+ {t('create_new_organization', 'Create New Organization')} + +
+ )} )} diff --git a/libraries/nestjs-libraries/src/database/prisma/organizations/organization.repository.ts b/libraries/nestjs-libraries/src/database/prisma/organizations/organization.repository.ts index aa1e970765..239219b713 100644 --- a/libraries/nestjs-libraries/src/database/prisma/organizations/organization.repository.ts +++ b/libraries/nestjs-libraries/src/database/prisma/organizations/organization.repository.ts @@ -496,4 +496,36 @@ export class OrganizationRepository { }, }); } + + createOrgForUser(userId: string, name: string, orgId?: string) { + return this._organization.model.organization.create({ + data: { + ...(orgId ? { id: orgId } : {}), + name, + apiKey: AuthService.fixedEncryption(makeId(20)), + allowTrial: false, + isTrailing: false, + users: { + create: { + role: Role.SUPERADMIN, + userId, + }, + }, + }, + select: { + id: true, + }, + }); + } + + getOrganizationName(orgId: string) { + return this._organization.model.organization.findUnique({ + where: { + id: orgId, + }, + select: { + name: true, + }, + }); + } } diff --git a/libraries/nestjs-libraries/src/database/prisma/organizations/organization.service.ts b/libraries/nestjs-libraries/src/database/prisma/organizations/organization.service.ts index 288622c171..86769ec69d 100644 --- a/libraries/nestjs-libraries/src/database/prisma/organizations/organization.service.ts +++ b/libraries/nestjs-libraries/src/database/prisma/organizations/organization.service.ts @@ -218,6 +218,47 @@ export class OrganizationService { return this._organizationRepository.deleteOrganization(orgId); } + async createOrgForUser( + userId: string, + body: { name?: string }, + userAuthHeader?: string + ) { + let orgId: string | undefined; + let orgName = body.name || 'New Organization'; + + // If user is authenticated via DOS ID, delegate creation to api.dos.me (Method 3: Delegated Creation) + const apiUrl = process.env.POSTIZ_OAUTH_URL || 'https://api.dos.me'; + if (userAuthHeader && userAuthHeader.startsWith('Bearer ')) { + try { + const response = await fetch(`${apiUrl}/organizations`, { + method: 'POST', + headers: { + Authorization: userAuthHeader, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: orgName, + }), + }); + if (response.ok) { + const data = await response.json(); + if (data?.id) { + orgId = data.id; + orgName = data.name || orgName; + } + } + } catch (err) { + // Fallback to local creation if remote call fails + } + } + + return this._organizationRepository.createOrgForUser(userId, orgName, orgId); + } + + async getOrganizationName(orgId: string) { + return this._organizationRepository.getOrganizationName(orgId); + } + async findOrgByName(name: string) { return this._organizationRepository.findOrgByName(name); } diff --git a/libraries/nestjs-libraries/src/dtos/organizations/create.organization.dto.ts b/libraries/nestjs-libraries/src/dtos/organizations/create.organization.dto.ts new file mode 100644 index 0000000000..a1b60bcc0c --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/organizations/create.organization.dto.ts @@ -0,0 +1,8 @@ +import { IsOptional, IsString, MaxLength } from 'class-validator'; + +export class CreateOrganizationDto { + @IsString() + @IsOptional() + @MaxLength(100) + name?: string; +}