feat(auth): add organization management endpoints

Add missing organization features for Teams functionality:
- PUT /auth/organizations/:id - update organization
- DELETE /auth/organizations/:id - delete organization
- PATCH /auth/organizations/:orgId/members/:memberId/role - update member role
- GET /auth/organizations/:id/invitations - list org invitations
- GET /auth/invitations - list user invitations
- DELETE /auth/invitations/:id - cancel or reject invitation
This commit is contained in:
Till-JS 2026-02-16 12:43:38 +01:00
parent 9d618b107c
commit 5fe16b5eec
13 changed files with 1163 additions and 0 deletions

View file

@ -2,6 +2,8 @@ import {
Controller,
Post,
Get,
Put,
Patch,
Delete,
Body,
Param,
@ -29,6 +31,8 @@ import { ResendVerificationDto } from './dto/resend-verification.dto';
import { UpdateProfileDto } from './dto/update-profile.dto';
import { ChangePasswordDto } from './dto/change-password.dto';
import { DeleteAccountDto } from './dto/delete-account.dto';
import { UpdateOrganizationDto } from './dto/update-organization.dto';
import { UpdateMemberRoleDto } from './dto/update-member-role.dto';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import type { CurrentUserData } from '../common/decorators/current-user.decorator';
@ -534,6 +538,162 @@ export class AuthController {
});
}
/**
* Update organization
*
* Updates an organization's name, logo, or metadata.
* Requires owner or admin role.
*/
@Put('organizations/:id')
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@ApiBearerAuth('JWT-auth')
@ApiOperation({
summary: 'Update organization',
description: 'Update organization name, logo, or metadata. Requires admin or owner role.',
})
@ApiBody({ type: UpdateOrganizationDto })
@ApiResponse({ status: 200, description: 'Organization updated successfully' })
@ApiResponse({ status: 401, description: 'Not authenticated' })
@ApiResponse({ status: 403, description: 'No permission to update organization' })
@ApiResponse({ status: 404, description: 'Organization not found' })
async updateOrganization(
@Param('id') id: string,
@Body() dto: UpdateOrganizationDto,
@Headers('authorization') authorization: string
) {
const token = this.extractToken(authorization);
return this.betterAuthService.updateOrganization(id, dto, token);
}
/**
* Delete organization
*
* Permanently deletes an organization and all its data.
* Requires owner role.
*/
@Delete('organizations/:id')
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.NO_CONTENT)
@ApiBearerAuth('JWT-auth')
@ApiOperation({
summary: 'Delete organization',
description: 'Permanently delete an organization. Only the owner can delete.',
})
@ApiResponse({ status: 204, description: 'Organization deleted successfully' })
@ApiResponse({ status: 401, description: 'Not authenticated' })
@ApiResponse({ status: 403, description: 'Only owner can delete organization' })
@ApiResponse({ status: 404, description: 'Organization not found' })
async deleteOrganization(
@Param('id') id: string,
@Headers('authorization') authorization: string
) {
const token = this.extractToken(authorization);
await this.betterAuthService.deleteOrganization(id, token);
}
/**
* Update member role
*
* Changes a member's role within an organization.
* Requires owner or admin role.
*/
@Patch('organizations/:orgId/members/:memberId/role')
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@ApiBearerAuth('JWT-auth')
@ApiOperation({
summary: 'Update member role',
description: "Change a member's role. Requires admin or owner role.",
})
@ApiBody({ type: UpdateMemberRoleDto })
@ApiResponse({ status: 200, description: 'Member role updated successfully' })
@ApiResponse({ status: 401, description: 'Not authenticated' })
@ApiResponse({ status: 403, description: 'No permission to change roles' })
@ApiResponse({ status: 404, description: 'Member not found' })
async updateMemberRole(
@Param('orgId') orgId: string,
@Param('memberId') memberId: string,
@Body() dto: UpdateMemberRoleDto,
@Headers('authorization') authorization: string
) {
const token = this.extractToken(authorization);
return this.betterAuthService.updateMemberRole(orgId, memberId, dto.role, token);
}
/**
* List organization invitations
*
* Returns all pending invitations for an organization.
* Requires owner or admin role.
*/
@Get('organizations/:id/invitations')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({
summary: 'List organization invitations',
description: 'Get all pending invitations for an organization.',
})
@ApiResponse({ status: 200, description: 'Returns list of invitations' })
@ApiResponse({ status: 401, description: 'Not authenticated' })
async listOrganizationInvitations(
@Param('id') id: string,
@Headers('authorization') authorization: string
) {
const token = this.extractToken(authorization);
return this.betterAuthService.listOrganizationInvitations(id, token);
}
/**
* List user's pending invitations
*
* Returns all pending invitations for the authenticated user.
*/
@Get('invitations')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({
summary: 'List user invitations',
description: 'Get all pending invitations for the current user.',
})
@ApiResponse({ status: 200, description: 'Returns list of invitations' })
@ApiResponse({ status: 401, description: 'Not authenticated' })
async listUserInvitations(@Headers('authorization') authorization: string) {
const token = this.extractToken(authorization);
return this.betterAuthService.listUserInvitations(token);
}
/**
* Cancel or reject invitation
*
* Cancels an invitation (for org admins) or rejects it (for invitees).
* The system automatically determines which action to take based on the user's role.
*/
@Delete('invitations/:id')
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.NO_CONTENT)
@ApiBearerAuth('JWT-auth')
@ApiOperation({
summary: 'Cancel or reject invitation',
description:
'Cancel (as org admin/owner) or reject (as invitee) a pending invitation.',
})
@ApiResponse({ status: 204, description: 'Invitation cancelled/rejected successfully' })
@ApiResponse({ status: 401, description: 'Not authenticated' })
@ApiResponse({ status: 404, description: 'Invitation not found' })
async cancelOrRejectInvitation(
@Param('id') id: string,
@Headers('authorization') authorization: string
) {
const token = this.extractToken(authorization);
// Try cancel first (for org owners/admins), if fails try reject (for invitees)
try {
await this.betterAuthService.cancelInvitation(id, token);
} catch {
await this.betterAuthService.rejectInvitation(id, token);
}
}
// =========================================================================
// Helper Methods
// =========================================================================

View file

@ -14,6 +14,8 @@ export { RegisterB2BDto } from './register-b2b.dto';
export { InviteEmployeeDto } from './invite-employee.dto';
export { AcceptInvitationDto } from './accept-invitation.dto';
export { SetActiveOrganizationDto } from './set-active-organization.dto';
export { UpdateOrganizationDto } from './update-organization.dto';
export { UpdateMemberRoleDto } from './update-member-role.dto';
// Password management DTOs
export { ForgotPasswordDto } from './forgot-password.dto';

View file

@ -0,0 +1,19 @@
import { IsString, IsIn } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
/**
* DTO for updating a member's role within an organization
*
* Note: 'owner' role cannot be assigned via this endpoint.
* To transfer ownership, use the dedicated transfer ownership endpoint.
*/
export class UpdateMemberRoleDto {
@ApiProperty({
description: 'New role for the member',
enum: ['admin', 'member'],
example: 'admin',
})
@IsString()
@IsIn(['admin', 'member'])
role: 'admin' | 'member';
}

View file

@ -0,0 +1,38 @@
import { IsString, IsOptional, MaxLength, MinLength } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
/**
* DTO for updating an organization
*
* All fields are optional - only provided fields will be updated.
*/
export class UpdateOrganizationDto {
@ApiPropertyOptional({
description: 'New name for the organization',
minLength: 2,
maxLength: 255,
example: 'Acme Corporation',
})
@IsString()
@IsOptional()
@MinLength(2)
@MaxLength(255)
name?: string;
@ApiPropertyOptional({
description: 'URL to organization logo',
maxLength: 500,
example: 'https://example.com/logo.png',
})
@IsString()
@IsOptional()
@MaxLength(500)
logo?: string;
@ApiPropertyOptional({
description: 'Additional metadata for the organization',
example: { industry: 'Technology', size: 'Enterprise' },
})
@IsOptional()
metadata?: Record<string, unknown>;
}

View file

@ -58,6 +58,7 @@ import type {
ValidateTokenResult,
TokenPayload,
OrganizationMember,
OrganizationInvitation,
Organization,
BetterAuthAPI,
SignUpResponse,
@ -721,6 +722,252 @@ export class BetterAuthService {
}
}
/**
* Update organization
*
* Updates an organization's name, logo, or metadata.
* Requires owner or admin role.
*
* @param organizationId - Organization ID
* @param data - Fields to update (name, logo, metadata)
* @param token - User's authentication token
* @returns Updated organization
* @throws ForbiddenException if user lacks permission
* @throws NotFoundException if organization not found
*/
async updateOrganization(
organizationId: string,
data: { name?: string; logo?: string; metadata?: Record<string, unknown> },
token: string
): Promise<Organization> {
try {
const result = await (this.orgApi as any).updateOrganization({
body: {
organizationId,
data: {
...(data.name !== undefined && { name: data.name }),
...(data.logo !== undefined && { logo: data.logo }),
...(data.metadata !== undefined && { metadata: data.metadata }),
},
},
headers: {
authorization: `Bearer ${token}`,
},
});
return result;
} catch (error: unknown) {
if (error instanceof Error) {
if (error.message?.includes('not found')) {
throw new NotFoundException('Organization not found');
}
if (error.message?.includes('permission') || error.message?.includes('unauthorized')) {
throw new ForbiddenException('You do not have permission to update this organization');
}
}
throw error;
}
}
/**
* Delete organization
*
* Deletes an organization and all its data.
* Requires owner role.
*
* @param organizationId - Organization ID
* @param token - User's authentication token
* @throws ForbiddenException if user is not the owner
* @throws NotFoundException if organization not found
*/
async deleteOrganization(organizationId: string, token: string): Promise<void> {
try {
await (this.orgApi as any).deleteOrganization({
body: { organizationId },
headers: {
authorization: `Bearer ${token}`,
},
});
} catch (error: unknown) {
if (error instanceof Error) {
if (error.message?.includes('not found')) {
throw new NotFoundException('Organization not found');
}
if (error.message?.includes('permission') || error.message?.includes('unauthorized')) {
throw new ForbiddenException('Only the owner can delete the organization');
}
}
throw error;
}
}
/**
* Update member role
*
* Changes a member's role within an organization.
* Requires owner or admin role.
*
* @param organizationId - Organization ID
* @param memberId - Member ID to update
* @param role - New role ('admin' or 'member')
* @param token - User's authentication token
* @returns Updated member
* @throws ForbiddenException if user lacks permission
* @throws NotFoundException if member not found
*/
async updateMemberRole(
organizationId: string,
memberId: string,
role: 'admin' | 'member',
token: string
): Promise<OrganizationMember> {
try {
const result = await (this.orgApi as any).updateMemberRole({
body: {
organizationId,
memberId,
role,
},
headers: {
authorization: `Bearer ${token}`,
},
});
return result?.member || result;
} catch (error: unknown) {
if (error instanceof Error) {
if (error.message?.includes('not found')) {
throw new NotFoundException('Member not found');
}
if (error.message?.includes('permission') || error.message?.includes('unauthorized')) {
throw new ForbiddenException('You do not have permission to change member roles');
}
if (error.message?.includes('owner')) {
throw new ForbiddenException("Cannot change the owner's role");
}
}
throw error;
}
}
/**
* List organization invitations
*
* Returns all pending invitations for an organization.
* Requires owner or admin role.
*
* @param organizationId - Organization ID
* @param token - User's authentication token
* @returns List of invitations
*/
async listOrganizationInvitations(
organizationId: string,
token: string
): Promise<OrganizationInvitation[]> {
try {
const result = await (this.orgApi as any).listInvitations({
query: { organizationId },
headers: {
authorization: `Bearer ${token}`,
},
});
return result?.invitations || result || [];
} catch (error: unknown) {
this.logger.error(
'Failed to list organization invitations',
error instanceof Error ? error.stack : undefined
);
return [];
}
}
/**
* List user's pending invitations
*
* Returns all pending invitations for the authenticated user.
*
* @param token - User's authentication token
* @returns List of invitations
*/
async listUserInvitations(token: string): Promise<OrganizationInvitation[]> {
try {
const result = (await (this.orgApi as any).getInvitation)
? await (this.orgApi as any).listUserInvitations({
headers: {
authorization: `Bearer ${token}`,
},
})
: [];
return result?.invitations || result || [];
} catch (error: unknown) {
this.logger.error(
'Failed to list user invitations',
error instanceof Error ? error.stack : undefined
);
return [];
}
}
/**
* Cancel an invitation
*
* Cancels a pending invitation. Used by organization admins/owners.
*
* @param invitationId - Invitation ID
* @param token - User's authentication token
* @throws ForbiddenException if user lacks permission
* @throws NotFoundException if invitation not found
*/
async cancelInvitation(invitationId: string, token: string): Promise<void> {
try {
await (this.orgApi as any).cancelInvitation({
body: { invitationId },
headers: {
authorization: `Bearer ${token}`,
},
});
} catch (error: unknown) {
if (error instanceof Error) {
if (error.message?.includes('not found')) {
throw new NotFoundException('Invitation not found');
}
if (error.message?.includes('permission') || error.message?.includes('unauthorized')) {
throw new ForbiddenException('You do not have permission to cancel this invitation');
}
}
throw error;
}
}
/**
* Reject an invitation
*
* Rejects a pending invitation. Used by the invited user.
*
* @param invitationId - Invitation ID
* @param token - User's authentication token
* @throws NotFoundException if invitation not found
*/
async rejectInvitation(invitationId: string, token: string): Promise<void> {
try {
await (this.orgApi as any).rejectInvitation({
body: { invitationId },
headers: {
authorization: `Bearer ${token}`,
},
});
} catch (error: unknown) {
if (error instanceof Error) {
if (error.message?.includes('not found')) {
throw new NotFoundException('Invitation not found');
}
}
throw error;
}
}
// =========================================================================
// Token Management Methods
// =========================================================================