mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:41:09 +02:00
✨ 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:
parent
9d618b107c
commit
5fe16b5eec
13 changed files with 1163 additions and 0 deletions
|
|
@ -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
|
||||
// =========================================================================
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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
|
||||
// =========================================================================
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue