Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions apps/backend/src/api/routes/users.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down
67 changes: 64 additions & 3 deletions apps/frontend/src/components/layout/organization.selector.tsx
Original file line number Diff line number Diff line change
@@ -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]);
Comment on lines +19 to +33

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The create callback lacks error handling and does not reset the loading state if the request fails, which would leave the UI permanently disabled. Additionally, fetch and modals are missing from the dependency array of useCallback.

  const create = useCallback(async () => {
    setLoading(true);
    try {
      const response = await fetch('/user/organizations', {
        method: 'POST',
        body: JSON.stringify({ name }),
      });
      if (!response.ok) {
        throw new Error('Failed to create organization');
      }
      const data = await response.json();
      if (data?.id) {
        await fetch('/user/change-org', {
          method: 'POST',
          body: JSON.stringify({ id: data.id }),
        });
        modals.closeAll();
        window.location.reload();
      } else {
        setLoading(false);
      }
    } catch (error) {
      setLoading(false);
    }
  }, [name, fetch, modals]);

return (
<div className="relative flex gap-[10px] flex-col flex-1 p-[16px] pt-0">
<Input
value={name}
disableForm={true}
removeError={true}
onChange={(e) => setName(e.target.value)}
name="name"
label={t('organization_name', 'Organization name')}
placeholder={t('organization_name', 'Organization name')}
/>
<Button type="button" className="mt-[18px]" onClick={create} disabled={loading}>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Prevent users from submitting empty or whitespace-only organization names by adding validation to the button's disabled prop.

Suggested change
<Button type="button" className="mt-[18px]" onClick={create} disabled={loading}>
<Button type="button" className="mt-[18px]" onClick={create} disabled={loading || !name.trim()}>

{t('create', 'Create')}
</Button>
</div>
);
};

export const OrganizationSelector: FC<{ asOpenSelect?: boolean }> = ({
asOpenSelect,
}) => {
const t = useT();
const modals = useModals();
const fetch = useFetch();
const user = useUser();
const load = useCallback(async () => {
Expand Down Expand Up @@ -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: <CreateOrganization />,
});
}, [t]);
Comment on lines +87 to +96

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The modals object is used inside the createOrg callback but is missing from the dependency array of useCallback.

Suggested change
const createOrg = useCallback(() => {
modals.openModal({
classNames: {
modal: 'bg-transparent text-textColor',
},
title: t('create_new_organization', 'Create New Organization'),
withCloseButton: true,
children: <CreateOrganization />,
});
}, [t]);
const createOrg = useCallback(() => {
modals.openModal({
classNames: {
modal: 'bg-transparent text-textColor',
},
title: t('create_new_organization', 'Create New Organization'),
withCloseButton: true,
children: <CreateOrganization />,
});
}, [t, modals]);

if (isLoading) {
return null;
}
return (
Expand Down Expand Up @@ -68,7 +124,7 @@ export const OrganizationSelector: FC<{ asOpenSelect?: boolean }> = ({
)}
</div>
)}
{data?.length > 1 && (
{(data?.length > 1 || !asOpenSelect) && (
<div
className={clsx(
'hidden py-[12px] px-[12px] group-hover:flex absolute top-[100%] end-0 w-max max-w-[400px] bg-third border-tableBorder border gap-[12px] cursor-pointer flex-col',
Expand Down Expand Up @@ -102,6 +158,11 @@ export const OrganizationSelector: FC<{ asOpenSelect?: boolean }> = ({
</div>
)
)}
{!asOpenSelect && (
<div onClick={createOrg} className="whitespace-nowrap font-medium text-primary hover:underline">
{t('create_new_organization', 'Create New Organization')} +
</div>
)}
</div>
)}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Comment on lines +231 to +253

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The external fetch call to api.dos.me does not have a timeout configured. If the external service hangs, this request will block the event loop indefinitely. It is recommended to use an AbortController to enforce a timeout.

    if (userAuthHeader && userAuthHeader.startsWith('Bearer ')) {
      const controller = new AbortController();
      const timeoutId = setTimeout(() => controller.abort(), 5000);
      try {
        const response = await fetch(apiUrl + '/organizations', {
          method: 'POST',
          headers: {
            Authorization: userAuthHeader,
            'Content-Type': 'application/json',
          },
          body: JSON.stringify({
            name: orgName,
          }),
          signal: controller.signal,
        });
        clearTimeout(timeoutId);
        if (response.ok) {
          const data = await response.json();
          if (data?.id) {
            orgId = data.id;
            orgName = data.name || orgName;
          }
        }
      } catch (err) {
        clearTimeout(timeoutId);
        // 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);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { IsOptional, IsString, MaxLength } from 'class-validator';

export class CreateOrganizationDto {
@IsString()
@IsOptional()
@MaxLength(100)
name?: string;
}
Loading