feat: API org user scope (#15739)
* Init organizations users GET endpoint * Fix get users endpoint * Add create user input * Add POST to organization user endpoint * Creating a user make email mandatory * Move DTOs out of platform types * Use plainToInstance for data filtering * POST create user and org membership * Add email check * Filter update user call * Add getOrg decorator to endpoints * Add update endpoint * Add delete endpoint * Fix merge changes to create new org user * Init tests * Send org signup email * Abstract username check * Implement email service * WIP E2E test * Rename methods * Update update org user DTO * Remove unused inputs * chore: add ApiProperty decorator to users class validators * Type fix * Update tests * chore: code review comments * chore: code review comments * chore: code review comments --------- Co-authored-by: Morgan Vernay <morgan@cal.com> Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
This commit is contained in:
co-authored by
Morgan Vernay
Morgan
parent
9fec77e345
commit
41cde8e384
@@ -0,0 +1,131 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Expose, Transform } from "class-transformer";
|
||||
import {
|
||||
IsBoolean,
|
||||
IsEmail,
|
||||
IsHexColor,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Validate,
|
||||
Min,
|
||||
} from "class-validator";
|
||||
|
||||
import { AvatarValidator } from "../validators/avatarValidator";
|
||||
import { LocaleValidator } from "../validators/localeValidator";
|
||||
import { ThemeValidator } from "../validators/themeValidator";
|
||||
import { TimeFormatValidator } from "../validators/timeFormatValidator";
|
||||
import { TimeZoneValidator } from "../validators/timeZoneValidator";
|
||||
import { WeekdayValidator } from "../validators/weekdayValidator";
|
||||
|
||||
export class CreateUserInput {
|
||||
@ApiProperty({ type: String, description: "User email address", example: "user@example.com" })
|
||||
@IsEmail()
|
||||
@Transform(({ value }) => {
|
||||
if (typeof value === "string") {
|
||||
return value.toLowerCase();
|
||||
}
|
||||
})
|
||||
@Expose()
|
||||
email!: string;
|
||||
|
||||
@ApiProperty({ type: String, required: false, description: "Username", example: "user123" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Transform(({ value }) => {
|
||||
if (typeof value === "string") {
|
||||
return value.toLowerCase();
|
||||
}
|
||||
})
|
||||
@Expose()
|
||||
username?: string;
|
||||
|
||||
@ApiProperty({ type: String, required: false, description: "Preferred weekday", example: "Monday" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Validate(WeekdayValidator)
|
||||
@Expose()
|
||||
weekday?: string;
|
||||
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
required: false,
|
||||
description: "Brand color in HEX format",
|
||||
example: "#FFFFFF",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsHexColor()
|
||||
@Expose()
|
||||
brandColor?: string;
|
||||
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
required: false,
|
||||
description: "Dark brand color in HEX format",
|
||||
example: "#000000",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsHexColor()
|
||||
@Expose()
|
||||
darkBrandColor?: string;
|
||||
|
||||
@ApiProperty({ type: Boolean, required: false, description: "Hide branding", example: false })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
@Expose()
|
||||
hideBranding?: boolean;
|
||||
|
||||
@ApiProperty({ type: String, required: false, description: "Time zone", example: "America/New_York" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Validate(TimeZoneValidator)
|
||||
@Expose()
|
||||
timeZone?: string;
|
||||
|
||||
@ApiProperty({ type: String, required: false, description: "Theme", example: "dark" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Validate(ThemeValidator)
|
||||
@Expose()
|
||||
theme?: string | null;
|
||||
|
||||
@ApiProperty({ type: String, required: false, description: "Application theme", example: "light" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Validate(ThemeValidator)
|
||||
@Expose()
|
||||
appTheme?: string | null;
|
||||
|
||||
@ApiProperty({ type: Number, required: false, description: "Time format", example: 24 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Validate(TimeFormatValidator)
|
||||
@Expose()
|
||||
timeFormat?: number;
|
||||
|
||||
@ApiProperty({ type: Number, required: false, description: "Default schedule ID", example: 1, minimum: 0 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Expose()
|
||||
defaultScheduleId?: number;
|
||||
|
||||
@ApiProperty({ type: String, required: false, description: "Locale", example: "en", default: "en" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Validate(LocaleValidator)
|
||||
@Expose()
|
||||
locale?: string | null = "en";
|
||||
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
required: false,
|
||||
description: "Avatar URL",
|
||||
example: "https://example.com/avatar.jpg",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Validate(AvatarValidator)
|
||||
@Expose()
|
||||
avatarUrl?: string;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Transform } from "class-transformer";
|
||||
import { IsOptional, Validate } from "class-validator";
|
||||
|
||||
import { SkipTakePagination } from "@calcom/platform-types";
|
||||
|
||||
import { IsEmailStringOrArray } from "../validators/isEmailStringOrArray";
|
||||
|
||||
export class GetUsersInput extends SkipTakePagination {
|
||||
@IsOptional()
|
||||
@Validate(IsEmailStringOrArray)
|
||||
@Transform(({ value }: { value: string | string[] }) => {
|
||||
return typeof value === "string" ? [value] : value;
|
||||
})
|
||||
@ApiProperty({
|
||||
description: "The email address or an array of email addresses to filter by",
|
||||
})
|
||||
emails?: string[];
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { CreateUserInput } from "@/modules/users/inputs/create-user.input";
|
||||
import { PartialType } from "@nestjs/mapped-types";
|
||||
|
||||
export class UpdateUserInput extends PartialType(CreateUserInput) {}
|
||||
@@ -0,0 +1,239 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { Expose } from "class-transformer";
|
||||
import { IsBoolean, IsDateString, IsInt, IsString, ValidateNested, IsArray } from "class-validator";
|
||||
|
||||
export class GetUserOutput {
|
||||
@IsInt()
|
||||
@Expose()
|
||||
@ApiProperty({ type: Number, required: true, description: "The ID of the user", example: 1 })
|
||||
id!: number;
|
||||
|
||||
@IsString()
|
||||
@Expose()
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
nullable: true,
|
||||
required: false,
|
||||
description: "The username of the user",
|
||||
example: "john_doe",
|
||||
})
|
||||
username!: string | null;
|
||||
|
||||
@IsString()
|
||||
@Expose()
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
nullable: true,
|
||||
required: false,
|
||||
description: "The name of the user",
|
||||
example: "John Doe",
|
||||
})
|
||||
name!: string | null;
|
||||
|
||||
@IsString()
|
||||
@Expose()
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
required: true,
|
||||
description: "The email of the user",
|
||||
example: "john@example.com",
|
||||
})
|
||||
email!: string;
|
||||
|
||||
@IsDateString()
|
||||
@Expose()
|
||||
@ApiProperty({
|
||||
type: Date,
|
||||
nullable: true,
|
||||
required: false,
|
||||
description: "The date when the email was verified",
|
||||
example: "2022-01-01T00:00:00Z",
|
||||
})
|
||||
emailVerified!: Date | null;
|
||||
|
||||
@IsString()
|
||||
@Expose()
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
nullable: true,
|
||||
required: false,
|
||||
description: "The bio of the user",
|
||||
example: "I am a software developer",
|
||||
})
|
||||
bio!: string | null;
|
||||
|
||||
@IsString()
|
||||
@Expose()
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
nullable: true,
|
||||
required: false,
|
||||
description: "The URL of the user's avatar",
|
||||
example: "https://example.com/avatar.jpg",
|
||||
})
|
||||
avatarUrl!: string | null;
|
||||
|
||||
@IsString()
|
||||
@Expose()
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
required: true,
|
||||
description: "The time zone of the user",
|
||||
example: "America/New_York",
|
||||
})
|
||||
timeZone!: string;
|
||||
|
||||
@IsString()
|
||||
@Expose()
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
required: true,
|
||||
description: "The week start day of the user",
|
||||
example: "Monday",
|
||||
})
|
||||
weekStart!: string;
|
||||
|
||||
@IsString()
|
||||
@Expose()
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
nullable: true,
|
||||
required: false,
|
||||
description: "The app theme of the user",
|
||||
example: "light",
|
||||
})
|
||||
appTheme!: string | null;
|
||||
|
||||
@IsString()
|
||||
@Expose()
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
nullable: true,
|
||||
required: false,
|
||||
description: "The theme of the user",
|
||||
example: "default",
|
||||
})
|
||||
theme!: string | null;
|
||||
|
||||
@IsInt()
|
||||
@Expose()
|
||||
@ApiProperty({
|
||||
type: Number,
|
||||
nullable: true,
|
||||
required: false,
|
||||
description: "The ID of the default schedule for the user",
|
||||
example: 1,
|
||||
})
|
||||
defaultScheduleId!: number | null;
|
||||
|
||||
@IsString()
|
||||
@Expose()
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
nullable: true,
|
||||
required: false,
|
||||
description: "The locale of the user",
|
||||
example: "en-US",
|
||||
})
|
||||
locale!: string | null;
|
||||
|
||||
@IsInt()
|
||||
@Expose()
|
||||
@ApiProperty({
|
||||
type: Number,
|
||||
nullable: true,
|
||||
required: false,
|
||||
description: "The time format of the user",
|
||||
example: 12,
|
||||
})
|
||||
timeFormat!: number | null;
|
||||
|
||||
@IsBoolean()
|
||||
@Expose()
|
||||
@ApiProperty({
|
||||
type: Boolean,
|
||||
required: true,
|
||||
description: "Whether to hide branding for the user",
|
||||
example: false,
|
||||
})
|
||||
hideBranding!: boolean;
|
||||
|
||||
@IsString()
|
||||
@Expose()
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
nullable: true,
|
||||
required: false,
|
||||
description: "The brand color of the user",
|
||||
example: "#ffffff",
|
||||
})
|
||||
brandColor!: string | null;
|
||||
|
||||
@IsString()
|
||||
@Expose()
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
nullable: true,
|
||||
required: false,
|
||||
description: "The dark brand color of the user",
|
||||
example: "#000000",
|
||||
})
|
||||
darkBrandColor!: string | null;
|
||||
|
||||
@IsBoolean()
|
||||
@Expose()
|
||||
@ApiProperty({
|
||||
type: Boolean,
|
||||
nullable: true,
|
||||
required: false,
|
||||
description: "Whether dynamic booking is allowed for the user",
|
||||
example: true,
|
||||
})
|
||||
allowDynamicBooking!: boolean | null;
|
||||
|
||||
@IsDateString()
|
||||
@Expose()
|
||||
@ApiProperty({
|
||||
type: Date,
|
||||
required: true,
|
||||
description: "The date when the user was created",
|
||||
example: "2022-01-01T00:00:00Z",
|
||||
})
|
||||
createdDate!: Date;
|
||||
|
||||
@IsBoolean()
|
||||
@Expose()
|
||||
@ApiProperty({
|
||||
type: Boolean,
|
||||
nullable: true,
|
||||
required: false,
|
||||
description: "Whether the user is verified",
|
||||
example: true,
|
||||
})
|
||||
verified!: boolean | null;
|
||||
|
||||
@IsInt()
|
||||
@Expose()
|
||||
@ApiProperty({
|
||||
type: Number,
|
||||
nullable: true,
|
||||
required: false,
|
||||
description: "The ID of the user who invited this user",
|
||||
example: 1,
|
||||
})
|
||||
invitedTo!: number | null;
|
||||
}
|
||||
|
||||
export class GetUsersOutput {
|
||||
@ValidateNested()
|
||||
@Type(() => GetUserOutput)
|
||||
@IsArray()
|
||||
@ApiProperty({
|
||||
type: [GetUserOutput],
|
||||
required: true,
|
||||
description: "The list of users",
|
||||
example: [{ id: 1, username: "john_doe", name: "John Doe", email: "john@example.com" }],
|
||||
})
|
||||
users!: GetUserOutput[];
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ValidatorConstraint } from "class-validator";
|
||||
import type { ValidatorConstraintInterface } from "class-validator";
|
||||
|
||||
@ValidatorConstraint({ name: "avatarValidator", async: false })
|
||||
export class AvatarValidator implements ValidatorConstraintInterface {
|
||||
validate(avatarString: string) {
|
||||
// Checks if avatar string is a valid base 64 image
|
||||
const regex = /^data:image\/[^;]+;base64,(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
|
||||
return regex.test(avatarString);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { ValidatorConstraint } from "class-validator";
|
||||
import type { ValidatorConstraintInterface } from "class-validator";
|
||||
|
||||
@ValidatorConstraint({ name: "IsEmailStringOrArray", async: false })
|
||||
export class IsEmailStringOrArray implements ValidatorConstraintInterface {
|
||||
validate(value: any): boolean {
|
||||
if (typeof value === "string") {
|
||||
return this.validateEmail(value);
|
||||
} else if (Array.isArray(value)) {
|
||||
return value.every((item) => this.validateEmail(item));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
validateEmail(email: string): boolean {
|
||||
const regex =
|
||||
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
|
||||
return regex.test(email);
|
||||
}
|
||||
|
||||
defaultMessage() {
|
||||
return "Please submit only valid email addresses";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { ValidatorConstraintInterface } from "class-validator";
|
||||
import { ValidatorConstraint } from "class-validator";
|
||||
|
||||
@ValidatorConstraint({ name: "localeValidator", async: false })
|
||||
export class LocaleValidator implements ValidatorConstraintInterface {
|
||||
validate(locale: string) {
|
||||
const localeValues = [
|
||||
"en",
|
||||
"fr",
|
||||
"it",
|
||||
"ru",
|
||||
"es",
|
||||
"de",
|
||||
"pt",
|
||||
"ro",
|
||||
"nl",
|
||||
"pt-BR",
|
||||
"ko",
|
||||
"ja",
|
||||
"pl",
|
||||
"ar",
|
||||
"iw",
|
||||
"zh-CN",
|
||||
"zh-TW",
|
||||
"cs",
|
||||
"sr",
|
||||
"sv",
|
||||
"vi",
|
||||
];
|
||||
|
||||
if (localeValues.includes(locale)) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
defaultMessage() {
|
||||
return "Please include a valid locale";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { ValidatorConstraintInterface } from "class-validator";
|
||||
import { ValidatorConstraint } from "class-validator";
|
||||
|
||||
@ValidatorConstraint({ name: "themeValidator", async: false })
|
||||
export class ThemeValidator implements ValidatorConstraintInterface {
|
||||
validate(theme: string) {
|
||||
const themeValues = ["dark", "light"];
|
||||
|
||||
if (themeValues.includes(theme)) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
defaultMessage() {
|
||||
return "Please include either 'dark' or 'light";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { ValidatorConstraintInterface } from "class-validator";
|
||||
import { ValidatorConstraint } from "class-validator";
|
||||
|
||||
@ValidatorConstraint({ name: "timeFormatValidator", async: false })
|
||||
export class TimeFormatValidator implements ValidatorConstraintInterface {
|
||||
validate(timeFormat: number) {
|
||||
const timeFormatValues = [12, 24];
|
||||
|
||||
if (timeFormatValues.includes(timeFormat)) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
defaultMessage() {
|
||||
return "Please include either 12 or 24";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { ValidatorConstraintInterface } from "class-validator";
|
||||
import { ValidatorConstraint } from "class-validator";
|
||||
import tzdata from "tzdata";
|
||||
|
||||
@ValidatorConstraint({ name: "timezoneValidator", async: false })
|
||||
export class TimeZoneValidator implements ValidatorConstraintInterface {
|
||||
validate(timeZone: string) {
|
||||
const timeZoneList = Object.keys(tzdata.zones);
|
||||
|
||||
if (timeZoneList.includes(timeZone)) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
defaultMessage() {
|
||||
return "Please include a valid time zone";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { ValidatorConstraintInterface } from "class-validator";
|
||||
import { ValidatorConstraint } from "class-validator";
|
||||
|
||||
@ValidatorConstraint({ name: "weekdayValidator", async: false })
|
||||
export class WeekdayValidator implements ValidatorConstraintInterface {
|
||||
validate(weekday: string) {
|
||||
const weekdays = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||
|
||||
if (weekdays.includes(weekday)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
defaultMessage() {
|
||||
return "Please include a valid weekday";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user