diff --git a/packages/models/src/config/behaviors/api.ts b/packages/models/src/config/behaviors/api.ts index 23613d01..d2d31944 100644 --- a/packages/models/src/config/behaviors/api.ts +++ b/packages/models/src/config/behaviors/api.ts @@ -5,6 +5,7 @@ import { apiServerBehaviors } from "../../server/Server/behaviors/index.js"; import { apiCustomerBehaviors } from "../../customer/Customer/behaviors/index.js"; import { apiIngressBehaviors } from "../../domain/Ingress/behaviors/index.js"; import { apiAppInstallationBehaviors } from "../../app/AppInstallation/behaviors/index.js"; +import { apiUserBehaviors } from "../../user/User/behaviors/index.js"; class ApiSetupState { private _client: MittwaldAPIV2Client | undefined; @@ -22,6 +23,7 @@ class ApiSetupState { config.behaviors.customer = apiCustomerBehaviors(client); config.behaviors.ingress = apiIngressBehaviors(client); config.behaviors.appInstallation = apiAppInstallationBehaviors(client); + config.behaviors.user = apiUserBehaviors(client); } public setupWithApiToken(apiToken: string) { diff --git a/packages/models/src/config/config.ts b/packages/models/src/config/config.ts index fbee52b8..959655bc 100644 --- a/packages/models/src/config/config.ts +++ b/packages/models/src/config/config.ts @@ -3,6 +3,7 @@ import { ServerBehaviors } from "../server/Server/behaviors/index.js"; import { CustomerBehaviors } from "../customer/Customer/behaviors/index.js"; import { IngressBehaviors } from "../domain/Ingress/behaviors/index.js"; import { AppInstallationBehaviors } from "../app/AppInstallation/behaviors/index.js"; +import { UserBehaviors } from "../user/User/behaviors/index.js"; interface Config { behaviors: { @@ -11,6 +12,7 @@ interface Config { customer: CustomerBehaviors; ingress: IngressBehaviors; appInstallation: AppInstallationBehaviors; + user: UserBehaviors; }; } @@ -21,5 +23,6 @@ export const config: Config = { customer: undefined as unknown as CustomerBehaviors, ingress: undefined as unknown as IngressBehaviors, appInstallation: undefined as unknown as AppInstallationBehaviors, + user: undefined as unknown as UserBehaviors, }, }; diff --git a/packages/models/src/user/User/User.ts b/packages/models/src/user/User/User.ts new file mode 100644 index 00000000..eb80e799 --- /dev/null +++ b/packages/models/src/user/User/User.ts @@ -0,0 +1,102 @@ +import { ReferenceModel } from "../../base/ReferenceModel.js"; +import { + type AsyncResourceVariant, + provideReact, +} from "../../lib/provideReact.js"; +import { config } from "../../config/config.js"; +import { DataModel } from "../../base/DataModel.js"; +import { classes } from "polytype"; +import { + UserAddPhoneNumberRequestData, + UserAuthenticateRequestData, + UserAuthenticateResponseData, + UserData, + UserUpdatePersonalInformationData, + UserVerifyPhoneNumberRequestData, +} from "./types.js"; +import assertObjectFound from "../../base/assertObjectFound.js"; + +export class User extends ReferenceModel { + public static ofId(id: string): User { + return new User(id); + } + + public static find = provideReact( + async (id: string): Promise => { + const data = await config.behaviors.user.find(id); + + if (data !== undefined) { + return new UserDetailed(data); + } + }, + ); + + public static get = provideReact( + async (id: string): Promise => { + const user = await this.find(id); + + assertObjectFound(user, this, id); + + return user; + }, + ); + + public static getOwn = provideReact(async (): Promise => { + return await this.get("self"); + }); + + public getDetailed = provideReact(() => + User.get(this.id), + ) as AsyncResourceVariant; + + public async updatePersonalInformation( + data: UserUpdatePersonalInformationData, + ): Promise { + await config.behaviors.user.updatePersonalInformation(this.id, data); + } + + public async addPhoneNumber( + data: UserAddPhoneNumberRequestData, + ): Promise { + await config.behaviors.user.addPhoneNumber(this.id, data); + } + + public async removePhoneNumber(): Promise { + await config.behaviors.user.removePhoneNumber(this.id); + } + + public async verifyPhoneNumber( + data: UserVerifyPhoneNumberRequestData, + ): Promise { + await config.behaviors.user.verifyPhoneNumber(this.id, data); + } + + public async requestAvatarUpload(): Promise<{ id: string }> { + return await config.behaviors.user.requestAvatarUpload(this.id); + } + + public async removeAvatar(): Promise { + await config.behaviors.user.removeAvatar(this.id); + } + + public static async authenticate( + data: UserAuthenticateRequestData, + ): Promise { + return await config.behaviors.user.authenticate(data); + } +} + +class UserCommon extends classes(DataModel, User) { + public readonly fullName: string; + + public constructor(data: UserData) { + super([data], [data.userId]); + this.fullName = `${data.person.firstName} ${data.person.lastName}`; + } +} + +export class UserDetailed extends classes(UserCommon, DataModel) { + public constructor(data: UserData) { + super([data], [data]); + } +} diff --git a/packages/models/src/user/User/behaviors/api.ts b/packages/models/src/user/User/behaviors/api.ts new file mode 100644 index 00000000..c58a6d8f --- /dev/null +++ b/packages/models/src/user/User/behaviors/api.ts @@ -0,0 +1,73 @@ +import { + assertStatus, + assertOneOfStatus, + MittwaldAPIV2Client, +} from "@mittwald/api-client"; +import { UserBehaviors } from "./types.js"; +import { UserAuthenticateRequestData } from "../types.js"; + +export const apiUserBehaviors = ( + client: MittwaldAPIV2Client, +): UserBehaviors => ({ + find: async (id) => { + const response = await client.user.getUser({ userId: id }); + + if (response.status === 200) { + return response.data; + } + assertOneOfStatus(response, [403, 404]); + }, + + updatePersonalInformation: async (id, data) => { + const response = await client.user.updatePersonalInformation({ + userId: id, + data, + }); + + assertStatus(response, 204); + }, + + addPhoneNumber: async (id, data) => { + const response = await client.user.addPhoneNumber({ userId: id, data }); + + assertStatus(response, 204); + }, + + removePhoneNumber: async (id) => { + const response = await client.user.removePhoneNumber({ userId: id }); + + assertStatus(response, 204); + }, + + verifyPhoneNumber: async (id, data) => { + const response = await client.user.verifyPhoneNumber({ userId: id, data }); + + // ToDo: 400 abfangen? + + assertStatus(response, 204); + }, + + requestAvatarUpload: async (id) => { + const response = await client.user.requestAvatarUpload({ userId: id }); + + assertStatus(response, 200); + + return { id: response.data.refId }; + }, + + removeAvatar: async (id) => { + const response = await client.user.removeAvatar({ userId: id }); + + assertStatus(response, 204); + }, + + authenticate: async (data: UserAuthenticateRequestData) => { + const response = await client.user.authenticate({ data }); + + assertOneOfStatus(response, [200, 202]); + + // ToDo: 400/401 abfangen? + + return response.data; + }, +}); diff --git a/packages/models/src/user/User/behaviors/index.ts b/packages/models/src/user/User/behaviors/index.ts new file mode 100644 index 00000000..a7b74f7c --- /dev/null +++ b/packages/models/src/user/User/behaviors/index.ts @@ -0,0 +1,2 @@ +export * from "./api.js"; +export * from "./types.js"; diff --git a/packages/models/src/user/User/behaviors/types.ts b/packages/models/src/user/User/behaviors/types.ts new file mode 100644 index 00000000..d3a9c09a --- /dev/null +++ b/packages/models/src/user/User/behaviors/types.ts @@ -0,0 +1,34 @@ +import { + UserAddPhoneNumberRequestData, + UserAuthenticateRequestData, + UserAuthenticateResponseData, + UserData, + UserUpdatePersonalInformationData, + UserVerifyPhoneNumberRequestData, +} from "../types.js"; + +export interface UserBehaviors { + find: (id: string) => Promise; + + updatePersonalInformation: ( + id: string, + data: UserUpdatePersonalInformationData, + ) => Promise; + + addPhoneNumber: ( + id: string, + data: UserAddPhoneNumberRequestData, + ) => Promise; + removePhoneNumber: (id: string) => Promise; + verifyPhoneNumber: ( + id: string, + data: UserVerifyPhoneNumberRequestData, + ) => Promise; + + requestAvatarUpload: (id: string) => Promise<{ id: string }>; + removeAvatar: (id: string) => Promise; + + authenticate: ( + data: UserAuthenticateRequestData, + ) => Promise; +} diff --git a/packages/models/src/user/User/index.ts b/packages/models/src/user/User/index.ts new file mode 100644 index 00000000..c0901e63 --- /dev/null +++ b/packages/models/src/user/User/index.ts @@ -0,0 +1,2 @@ +export * from "./User.js"; +export * from "./types.js"; diff --git a/packages/models/src/user/User/types.ts b/packages/models/src/user/User/types.ts new file mode 100644 index 00000000..970cc3d2 --- /dev/null +++ b/packages/models/src/user/User/types.ts @@ -0,0 +1,19 @@ +import { MittwaldAPIV2 } from "@mittwald/api-client"; + +export type UserData = MittwaldAPIV2.Operations.UserGetUser.ResponseData; + +export type UserUpdatePersonalInformationData = + MittwaldAPIV2.Paths.V2UsersSelfPersonalInformation.Put.Parameters.RequestBody; + +export type UserAddPhoneNumberRequestData = + MittwaldAPIV2.Paths.V2UsersUserIdPhone.Post.Parameters.RequestBody; + +export type UserVerifyPhoneNumberRequestData = + MittwaldAPIV2.Paths.V2UsersUserIdActionsVerifyPhone.Post.Parameters.RequestBody; + +export type UserAuthenticateResponseData = + | MittwaldAPIV2.Paths.V2Authenticate.Post.Responses.$200.Content.ApplicationJson + | MittwaldAPIV2.Paths.V2Authenticate.Post.Responses.$202.Content.ApplicationJson; + +export type UserAuthenticateRequestData = + MittwaldAPIV2.Paths.V2Authenticate.Post.Parameters.RequestBody; diff --git a/packages/models/src/user/index.ts b/packages/models/src/user/index.ts new file mode 100644 index 00000000..8f9829a8 --- /dev/null +++ b/packages/models/src/user/index.ts @@ -0,0 +1 @@ +export * from "./User/index.js"; diff --git a/packages/models/src/user/old/Signup.ts b/packages/models/src/user/old/Signup.ts new file mode 100644 index 00000000..fe622507 --- /dev/null +++ b/packages/models/src/user/old/Signup.ts @@ -0,0 +1,321 @@ +/* +export class Signup { + + + public static async register( + values: RegistrationInputs, + isEmailInvite?: boolean, + ): Promise { + const result = await mittwaldApi.userRegister.request({ + requestBody: { + password: values.password, + email: values.email, + person: { + title: values.person.title, + firstName: values.person.firstName, + lastName: values.person.lastName, + }, + }, + }); + + if ( + result.status === 400 && + result.content.message?.includes("email must match format") + ) { + return "invalid_string"; + } + + assertStatus(result, 201); + + store.setProfileInformation(values); + store.setUserId(result.content.userId); + store.setIsEmailInvite(!!isEmailInvite); + + return result.content.userId; + } + + public static async verifyRegistration( + values: VerifyRegistrationInputs, + email: string, + password: string, + rejectionAnimation: AnimationController, + userId?: string, + appRedirect?: CallableFunction, + ): Promise { + if (!userId) { + throw new Error("userId must be set"); + } + + const autoLogin = async (): Promise => { + const authenticationResult = await mittwaldApi.userAuthenticate.request({ + requestBody: { + email, + password, + }, + }); + if (authenticationResult.status !== 200) { + throw authenticationResult; + } + const { token } = authenticationResult.content; + sessionStore.login(token); + appRedirect && appRedirect(); + }; + + const result = await mittwaldApi.userVerifyRegistration.request({ + requestBody: { + email, + token: values.token, + userId, + }, + }); + + if (result.status === 400) { + rejectionAnimation.start(); + throw new UnexpectedResponseError(result); + } + + assertStatus(result, 200); + + + await retryRunnable(autoLogin, { + retries: 5, + getRetryBackoff: constantRetryBackoff(2), + }); + + registerStore.clearProfileInformation(); + return; + } + + public static async verifyMfa( + values: VerifyMfaInputs, + email: string, + password: string, + rejectionAnimation: AnimationController, + ): Promise { + const result = await mittwaldApi.userAuthenticateMfa.request( + { + requestBody: { + multiFactorCode: values.multiFactorCode, + email, + password, + }, + }, + { + timeout: 30 * 1000, + }, + ); + + if (result.status !== 200) { + rejectionAnimation.start(); + throw new UnexpectedResponseError(result); + } + + sessionStore.login(result.content.token); + sessionStore.setMfaEnabled(true); + loginStore.clearFirstFactorInformation(); + } + + public static confirmMfa = async ( + multiFactorCode: string, + rejectionAnimation: AnimationController, + ): Promise => { + const res = await mittwaldApi.userConfirmMfa.request( + { + requestBody: { multiFactorCode }, + }, + { + timeout: 30 * 1000, + }, + ); + + if (res.status !== 200) { + rejectionAnimation.start(); + throw new UnexpectedResponseError(res); + } + + return res.content.recoveryCodesList; + }; + + public static removeMfa = async ( + multiFactorCode: string, + rejectionAnimation: AnimationController, + ): Promise => { + const res = await mittwaldApi.userDisableMfa.request( + { + requestBody: { multiFactorCode }, + }, + { + timeout: 30 * 1000, + }, + ); + + if (res.status !== 204) { + rejectionAnimation.start(); + throw new UnexpectedResponseError(res); + } + }; + + public static resetRecoveryCodes = async ( + multiFactorCode: string, + rejectionAnimation: AnimationController, + ): Promise => { + const res = await mittwaldApi.userResetRecoverycodes.request({ + requestBody: { multiFactorCode: multiFactorCode }, + }); + + if (res.status !== 200) { + rejectionAnimation.start(); + throw new UnexpectedResponseError(res); + } + + return res.content.recoveryCodesList; + }; + + public static verifyEmail = async ( + email: string, + token: string, + rejectionAnimation: AnimationController, + ): Promise => { + const response = await mittwaldApi.userVerifyEmail.request({ + requestBody: { + email, + token, + }, + }); + + if (response.status !== 204) { + rejectionAnimation.start(); + throw new UnexpectedResponseError(response); + } + }; + + public static useUserEmailAddress(): string { + return ( + mittwaldApi.userGetOwnAccount + .getResource({ path: { userId: "self" } }) + .useWatchData().email ?? + mittwaldApi.userGetOwnEmail.getResource({}).useWatchData().email + ); + } + + public static usePasswordUpdatedAt(): string { + return mittwaldApi.userGetPasswordUpdatedAt.getResource({}).useWatchData() + .passwordUpdatedAt; + } + + public static useMfaConfirmed(): boolean { + return mittwaldApi.userGetMfaStatus.getResource({}).useWatchData() + .confirmed; + } + + public static async updateEmailAddress( + values: UpdateEmailAddressInputs, + ): Promise { + const response = await mittwaldApi.userChangeEmail.request({ + requestBody: { + email: values.email, + }, + }); + + assertStatus(response, 204); + } + + public static async resendEmail( + values: ResendEmailInputs, + userId: string = "", + ): Promise { + const response = await mittwaldApi.userResendVerificationEmail.request({ + requestBody: { + userId, + email: values.email, + }, + }); + + assertStatus(response, 204); + } + + public static async changePassword( + values: ChangePasswordInputs, + ): Promise { + const response = await mittwaldApi.userChangePassword.request( + { + requestBody: { + oldPassword: values.oldPassword, + newPassword: values.newPassword, + multiFactorCode: values.multiFactorCode || undefined, + }, + }, + { + timeout: 30 * 1000, + }, + ); + + if (response.status === 202) { + return 202; + } + + assertStatus(response, 200); + + return response.content.token; + } + + public static async resetPassword( + values: ResetPasswordInputs, + ): Promise { + const result = await mittwaldApi.userInitPasswordReset.request({ + requestBody: { + email: values.email, + }, + }); + + assertStatus(result, 201); + } + + public static async comfirmResetPassword( + values: ConfirmPasswordResetInputs, + userId: string, + token: string, + ): Promise { + const result = await mittwaldApi.userConfirmPasswordReset.request({ + requestBody: { + userId, + token, + password: values.password, + }, + }); + + assertStatus(result, 204); + } + + public static async logout(): Promise { + const result = await mittwaldApi.userLogout.request({}); + + assertStatus(result, 204); + + sessionStore.logout(); + } + + public static useAccessTokenRetrievalKey(): AccessTokenRetrievalKey { + return mittwaldApi.userCreateAccessTokenRetrievalKey + .getResource({ path: { userId: "self" } }) + .useWatchData(); + } + + public static async delete( + values: DeleteProfileInputs, + ): Promise<409 | 400 | void> { + const response = await mittwaldApi.userDeleteUser.request({ + requestBody: { + password: values.password, + multiFactorCode: values.multiFactorCode, + }, + }); + + if (response.status === 409 || response.status === 400) { + return response.status; + } + + assertStatus(response, 200); + } +} +*/