diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index cacf035a6..3a6ec62f7 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -448,6 +448,7 @@ jobs: --selector app=keycloak \ --set config-cli.init.ref=${{ github.event_name == 'release' && github.ref_name || github.sha }} \ --set keycloak.themes.ref=${{ github.event_name == 'release' && github.ref_name || github.sha }} \ + --set keycloak.providers.ref=${{ github.event_name == 'release' && github.ref_name || github.sha }} \ --set postInstallHook.ref=${{ github.event_name == 'release' && github.ref_name || github.sha }} helmfile-version: ${{ env.HELMFILE_VERSION }} helm-version: ${{ env.HELM_VERSION }} diff --git a/.gitignore b/.gitignore index 7a407f37f..4213c6cfa 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,9 @@ **/public/workbox-*.js.map **/public/worker-*.js.map +# Ignore the Maven Target Dirs which contain build artifacts +src/keycloak/providers/**/target + # Created by https://www.toptal.com/developers/gitignore/api/node,yarn,linux,macos,csharp,nextjs,windows,aspnetcore,dotnetcore,sublimetext,intellij+all,visualstudio,visualstudiocode # Edit at https://www.toptal.com/developers/gitignore?templates=node,yarn,linux,macos,csharp,nextjs,windows,aspnetcore,dotnetcore,sublimetext,intellij+all,visualstudio,visualstudiocode diff --git a/.mise.toml b/.mise.toml index 913769860..7df970b78 100644 --- a/.mise.toml +++ b/.mise.toml @@ -1,8 +1,10 @@ # https://github.com/jdx/mise [tools] dotnet = "8.0" +java = "temurin-17" +maven = "3.9" node = "18" tilt = "latest" [env] -MISE_FETCH_REMOTE_VERSIONS_TIMEOUT="30s" # Because azure is slow +MISE_FETCH_REMOTE_VERSIONS_TIMEOUT = "30s" # Because azure is slow diff --git a/helm/keycloak/conf/dev/secrets.yaml b/helm/keycloak/conf/dev/secrets.yaml index ce0d4f369..156778b1f 100644 --- a/helm/keycloak/conf/dev/secrets.yaml +++ b/helm/keycloak/conf/dev/secrets.yaml @@ -29,6 +29,12 @@ keycloak: database: ENC[AES256_GCM,data:E0jLFFQazWU=,iv:svmiHaLI96ty8NLaLt6Ymj0dKdnUHOGeERqHLPckxdk=,tag:sUHlYtuTURA3Uji3MtboWQ==,type:str] user: ENC[AES256_GCM,data:WgSbrgPm0I4=,iv:+zNxjybnPaEc8hqz/8KiAgFnTqjy4YfBeD2FRyEMuyg=,tag:BQhC/umkzAzh8BC6/F2WBQ==,type:str] password: ENC[AES256_GCM,data:p6jOnvclPDA=,iv:5/4OGvNaEl/tsiKRItUZC1L2LnIAhuentEvtf/jZwss=,tag:7yd/4poPZiZZaK/Kwe9QVg==,type:str] + twilio: + stringData: + sid: ENC[AES256_GCM,data:EbpNjDcFQkiQLlBrDJYCQzXzNXEMKH7zWG7I0C5Dr5rsOg==,iv:K74xK1QVaJlc/CpT2P/fCIfHsBBUDyFRnsP1Te+WxEc=,tag:fdYt5DOhwUQHz/YFDq2PBg==,type:str] + token: ENC[AES256_GCM,data:KzmcqjjPt25hrYV6begodVpI3AN8A/THixX6HARv6Q8=,iv:P6YVUVGLG1tCi3xx6MzQ8yYMuofBBh4C5/VbOvwQ3Ns=,tag:BKgfDFg6huUJrbgpjAUERQ==,type:str] + #ENC[AES256_GCM,data:ykHupEfcYDqFs4bVvZTwjBvLuR3BO9Ln2BpsoE77zSbK9bQ+etPL782lC0NdcXgsMZX4WIjSywlJ,iv:EBV1vzcxg9209TM3IffsurUbYi/yUUhj/eml3nyd/jw=,tag:Cy7YEcqNXFtq6FHODvxc1w==,type:comment] + number: ENC[AES256_GCM,data:9Qr8eSnY+hLhWKKTfg==,iv:2ObU32lXXqL6/7Ndn+3Tmc3GZ5rCPBa0mAVnwxy/1QU=,tag:aX+2ftMl/Ez2ILTRr1Shxw==,type:str] config-cli: secrets: KEYCLOAK_USER: ENC[AES256_GCM,data:DQzrhhayQ2Q=,iv:NwgCJZuKx+D2gUcSc1T+Vv0LigPo8UFeiYgbBfvT0vM=,tag:arBptodK9nGgPVRqsk7zGQ==,type:str] @@ -54,8 +60,8 @@ sops: azure_kv: [] hc_vault: [] age: [] - lastmodified: "2024-07-29T07:21:28Z" - mac: ENC[AES256_GCM,data:f0IgtC3m48QjEfaxx23ZS6YFOkcCmhrCFZr4msYfltQ3k6qbk6Qbe7GvJLnWpHETB5+B6OjRVfPciC6p1Y3C4Juf+Xa++5PUPf9D5PxAKcNI/SL8LKhejgmARlE2JfbfYd7bWNJZvYJy6CGR+jlgE4RnxuU7XbYgKABxjMhF/A0=,iv:XF+41nyyJpG/y8vgU4xw3TUWsS1ABCnIZnBSbssIyPo=,tag:ZMyqGPn2MKRicykri5hKkQ==,type:str] + lastmodified: "2024-10-17T12:23:28Z" + mac: ENC[AES256_GCM,data:CrEaxNrwPaUdFoDmdFXThBwHb6bM4XcLvegSDWojmW1hCU2N3xwQlS2Dw+YeT8uYzskifdGYpfNGSi7zxLh8hjMFKH1xfoGoiO2MPf6kAZRycjv7NDr6zHm3sxEeTZkcDL3PuqgLVlKdE5T97HJzuNXken6sMpYLLzJq9GjQBHU=,iv:Hg64GnGl0A0qvSp1W8i/Wbp92AcqXxCkUp9SkOdM14w=,tag:OOZwDZvFsc59GaLSLsATRQ==,type:str] pgp: [] unencrypted_suffix: _unencrypted - version: 3.9.0 + version: 3.9.1 diff --git a/helm/keycloak/conf/prod/secrets.yaml b/helm/keycloak/conf/prod/secrets.yaml index be5839278..95dd2d51f 100644 --- a/helm/keycloak/conf/prod/secrets.yaml +++ b/helm/keycloak/conf/prod/secrets.yaml @@ -8,6 +8,12 @@ keycloak: #ENC[AES256_GCM,data:lltrqHjMjrp+ZaGwOXM=,iv:kTnOAn3acFbMjFUE1nZNigomN3LrtOx0U2n1SFwlBeg=,tag:YdbwJyOLZV7ws5JDvhzJWg==,type:comment] #ENC[AES256_GCM,data:VvwA2+9/yCyjQNc1JFM96yUxJr/orj07vnGjUY/hL3JB7saF7H6GSpBI,iv:4Xxc8MPEVT8SdFQSpfBBxPPoCCRGGQTdb8nw65+xnJ8=,tag:gLS1vBprSHHkTpkow6PjZA==,type:comment] #ENC[AES256_GCM,data:o8X/VsS2vcLN6OoG5Q02lJNjTL6GSBgVq5W8/znbImHez42a0IqPpLBnPna+Dq6Ij9LTzAF3mYYfsnXbLfKcRpxj,iv:PXb3RXOC3/9JfqoNqUTyukReRhu6cE7glEojY/cH6B4=,tag:QvPLh2JFJ7515FbUuzL6Wg==,type:comment] + twilio: + stringData: + sid: ENC[AES256_GCM,data:KEuBL0NsjuJFpaZc+9h4pRE6PFY9c20wb1F6Ifyoz7IfdQ==,iv:pvcw+rV6lhbNpL4sXhinBDrYYMyLN31v0lXcAdUIJcY=,tag:d26c1C+iCv/qAYnsi6GtQQ==,type:str] + token: ENC[AES256_GCM,data:8ttJmjNkFNzutX+SoPQE5QYQF7DqhvOvcwIDhn44eBM=,iv:KvP5WtISwDqHITqQWZ44FOt1s1F/z29GPuH5fVLXqq0=,tag:McdfdILIDXPREhLw85/4Pw==,type:str] + #ENC[AES256_GCM,data:kz0+uKQT9byVVhDoBFkxOmMlhxyWpNLx2zyMAkAKprharFVT8dD+HCPAs/sui6L5YnUA+zeHrx5D,iv:SqiqLrDJk86rGPpgBTvbr+vxHHQhQbkYHCWE1mhkA84=,tag:tJMvS2yOrxsc0cm33Lbhkw==,type:comment] + number: ENC[AES256_GCM,data:OnN8p8+wMHfbcVkJ/Q==,iv:kHVtAjX49TEMG8TVRIXHZ906WSlCX8bi0PxioVEV0bc=,tag:yNH3cN5WkVJD/DfGAK5SbQ==,type:str] config-cli: secrets: KEYCLOAK_USER: ENC[AES256_GCM,data:k/b9R1DS3lw=,iv:n4OGjLPXpPoyrxCtCz+BPmwwy+fDcD6aG1J1whUzuXw=,tag:Pd+vak3/ZukMe3fcRqHFpg==,type:str] @@ -33,8 +39,8 @@ sops: azure_kv: [] hc_vault: [] age: [] - lastmodified: "2024-07-29T07:21:52Z" - mac: ENC[AES256_GCM,data:V1e0QvQl4prFG7UXaQST2Oz/lzPOMk3Udr36pVsHK3lj9OXl1V4SuMzAWyLJjaMTBprDvkjh2xk5tkYjYwj6/6H/GnawHLdAdjpNQk4zd1v0iWP1KfngAOhNN4tDDuXekK5e5WrHUBoDvRyunXK2zgd27Im2IqtFczwgmZ/6mr8=,iv:jwh32O52eHk+DL9otJPxncTSgjG2fEoMA52QlvKzl3k=,tag:tbzXPsbGFWnZKfsOaCJBUQ==,type:str] + lastmodified: "2024-10-17T12:23:09Z" + mac: ENC[AES256_GCM,data:gq2Lh8mWvq+dqmpmSJvzq4BUftj3KpCmT0KJSS0ikNu0jhY9s5S0o2rHDkiSkpzZcxOzqFI1klramm81S7fMOu9xDtt0kkesvOUxNDMBbqZyj6cBh8S1NzfkpHdq52BUiH9PIWu3y+4h0cAOUwegJMVH4wDe0Bhdld4zhmYl3LE=,iv:QLP7AgaXb+eevFKDX2Bz3XBHrFKjn60sOXZlyydky+8=,tag:m4KCjVDGdBvuv5MJSfee/g==,type:str] pgp: [] unencrypted_suffix: _unencrypted - version: 3.9.0 + version: 3.9.1 diff --git a/helm/keycloak/conf/stage/secrets.yaml b/helm/keycloak/conf/stage/secrets.yaml index 6db4a1df6..a665cc1e6 100644 --- a/helm/keycloak/conf/stage/secrets.yaml +++ b/helm/keycloak/conf/stage/secrets.yaml @@ -4,10 +4,12 @@ keycloak: stringData: WEBHOOK_HTTP_AUTH_USERNAME: ENC[AES256_GCM,data:kdNoR0TG3g==,iv:SyrnoQetML1TaQ3Q0gx0qaw2twLaqByfCXC8p5h3/ac=,tag:bUTJWQ0VrGwySlaiyCHE9Q==,type:str] WEBHOOK_HTTP_AUTH_PASSWORD: ENC[AES256_GCM,data:OgP8nv4ob23E3btlwoTlFj+2ckJW5zU67RMX9lp4RLA=,iv:s2HqvlhzqGleTOJbC1z6oyzWiz6S5zu62zNx11y8Mic=,tag:U+Z8WCFhDrXrn2qqU0lQSA==,type:str] - #ENC[AES256_GCM,data:KthHAuqqxw==,iv:FlWBiukpHGyEBoJNNCKG/F3kMqi5mYwuRLPFfF9BUAk=,tag:2nHj2HoNX6ZrBI+Bbayldg==,type:comment] -#ENC[AES256_GCM,data:dZSqAcdfG1b3IhPYhAMtXZPbtizE+rQCyNt/bSeaOX/qY5ScBQqoxuHL4qtFlGZtGmiZtqvwMqEhUTc5fegaZ6ep83Z/HdJObRPtjmEAg42ORRPWOVaVmm9bI2nNh0MCwr8tjbvg2O5I1aqrETzJFo9zkZxvpmrhMd/mHJHHipJkBxs=,iv:q2u9QupkWkDEcw64qKr+5FHtdm56lBe6pSCw4f2r/A8=,tag:1atRQX1bp+/OcgkSHFK5fg==,type:comment] -#ENC[AES256_GCM,data:l+wobjYxdlJKFqISuazpFUNNlqaZ2zpjVnK3tFOX+l6FzDmh0MzXM6efypMdgRKXgR5XQaiXwlLgRLbtP8wxdZGnouVHH4F5uZwtg51NwvsbNiJ2iWU4Bf1OiAsaoeF+oyLinwjnQ8Xa2t48fbC8ZNWPqDlSTJ/dtcotBK+YcCfJZtFCrGxoROQXuwWp23obcp1HFLySGgKMrzr1anF3S1pvXJL/ik8=,iv:jJdJb3Bp/HKabP0LK1OYk6eQ7BMY1QYEx8vISvPKSZI=,tag:5p/lsUlBtqrda15LqjrhBA==,type:comment] -#ENC[AES256_GCM,data:tgOPq8BYWbSfNmyGejxhHaWiXVC6PZtoSN5k+0m015xh+0lWSSzqmJ67PhAzyDVDV+iHehe1IvCIsQ216FdHwpu05jsS1L1OeMtzaqSjYaIZvOPj5WY1dwzcsTRIH7tAGwxMCZW/JH+Ch6XE7UgYwnMSEmiH4qyZHoe+bctXu7sPfLZ/zP5ES6ZazDrKNVU5tutHqXZn2FlI7ezYaLTHlP0GlbG5EeJfZaMv,iv:rdlmfW6ppeYSgjraea3KUdOzQF9nooRedWUnjlAm/u0=,tag:CW8jwOt34izyVt9kX1dymg==,type:comment] + twilio: + stringData: + sid: ENC[AES256_GCM,data:qN5gQzOFjgrQ4q11PTWicyxoLOcoxLVAeXZO/7QNxZnEdQ==,iv:kY/3CiaQskm6D1Cwg/3+jAzIZwkx7oNZj2dw6jxmw+4=,tag:BWN1PdzGKmfFIYTwr1pp7Q==,type:str] + token: ENC[AES256_GCM,data:s2VLoA+o8m40gvTCGJEMFa2L/31eL5p76CdLkqhxyPU=,iv:mWEbVTmZx7xrDgvMoX/uE0+zj0/Pvk/YC4Cf+NDToXo=,tag:0UlDJ92BTRVC4BP4Gf8MVg==,type:str] + #ENC[AES256_GCM,data:M6KkbQRB60LcfAZRHj0Y6KK/h3HpZ8pXfKQAhd9nAUi+rlKD9GAlF962C1pWnZ6smr0Boie/SdQa,iv:EII2KqdcZzzYZIVhRekOhA6So5ZMyBIlLt5gFbzRP9A=,tag:Z3Vi1ixD3EhW+cijx69TKA==,type:comment] + number: ENC[AES256_GCM,data:L57ep1VM7KItOKnrjQ==,iv:h/jsS9OY+jRqv/F0snphkt3wAc5aAK86lVFcQgVfNE4=,tag:APBh9EsWfQFsLN8Tq/rHTg==,type:str] config-cli: secrets: KEYCLOAK_USER: ENC[AES256_GCM,data:eXMN0g6tu3I=,iv:+VFQ4+ug/ux/QKq8GQ4MgPMf/3sqlpgZPAOw3F6qZjg=,tag:AExr/1YRiWwaTRIFZzzdAA==,type:str] @@ -33,8 +35,8 @@ sops: azure_kv: [] hc_vault: [] age: [] - lastmodified: "2024-07-29T07:22:03Z" - mac: ENC[AES256_GCM,data:VzQ9L9QcGWQkPVIEIVGtqJ4FdQroAV8TUs1cX9dFL0FiCKF8fhnj0yEMhY46uQqhGMpl6ljDPJniLQA8rhQPsLE/qk7n2pDN6jQpvbYN+dvDv9sHkmK+JKPJf4P0PyTz3/ufvMbfKPvInA4l5rOL7NrQTkKu/R1bc7dYV67vKtY=,iv:pk85jVmdI5Tg6kV8ZdhRkeJ2bFMC9yCuT2zsF+pIi0g=,tag:mAkeu1+IlkvO34toSF9w+w==,type:str] + lastmodified: "2024-10-17T12:23:12Z" + mac: ENC[AES256_GCM,data:FSnTLi7tRmPTm2CKUtfAtqVu9vKUwtBOLHeqIW33bGAPVnN0Z7CzCvK4d3g4d9UAfgFPExb79gb0CzhvSgH79gsv8EeJQc9CcW56J4mNGPch1+FrtyktbTu4oG2FhXF00fR4Ed0XncvhET2SIRkuISq+BSEZ48P2bxuWXtsnqR8=,iv:/KVkYPoJyyYLEk06lioMvHU5iCvDBJxTgFaCRez4it8=,tag:Lob5O3lUTvCpUJTIVnMHDg==,type:str] pgp: [] unencrypted_suffix: _unencrypted - version: 3.9.0 + version: 3.9.1 diff --git a/helm/keycloak/values.yaml b/helm/keycloak/values.yaml index 07ab50f13..f25c8a550 100644 --- a/helm/keycloak/values.yaml +++ b/helm/keycloak/values.yaml @@ -196,8 +196,10 @@ keycloak: themes: enabled: false ref: develop + providers: + ref: develop extraInitContainers: |- - - name: download-extensions + - name: download-providers image: docker.io/busybox:stable imagePullPolicy: IfNotPresent command: @@ -205,9 +207,15 @@ keycloak: args: - -c - |- + cd /providers wget -q \ https://github.com/vymalo/keycloak-webhook/releases/download/v{{ .Values.webhook.version }}/keycloak-webhook-{{ .Values.webhook.version }}-all.jar \ -O /providers/keycloak-webhook-{{ .Values.webhook.version }}.jar + wget -qO - \ + --header="Accept:application/vnd.github.v3.raw" \ + https://api.github.com/repos/didx-xyz/yoma/tarball/{{ .Values.providers.ref }} | tar xz + cp -v ./didx-xyz-yoma-*/src/keycloak/providers/jars/*.jar /providers + rm -rf ./didx-xyz-yoma-* volumeMounts: - name: providers mountPath: /providers @@ -281,6 +289,27 @@ keycloak: value: sslmode=prefer - name: KC_LOG_CONSOLE_OUTPUT value: json + - name: KC_SPI_PHONE_DEFAULT_SERVICE + value: twilio + - name: KC_SPI_MESSAGE_SENDER_SERVICE_TWILIO_ACCOUNT + valueFrom: + secretKeyRef: + name: {{ include "keycloak.fullname" . }}-twilio + key: sid + - name: KC_SPI_MESSAGE_SENDER_SERVICE_TWILIO_TOKEN + valueFrom: + secretKeyRef: + name: {{ include "keycloak.fullname" . }}-twilio + key: token + - name: KC_SPI_MESSAGE_SENDER_SERVICE_TWILIO_NUMBER + valueFrom: + secretKeyRef: + name: {{ include "keycloak.fullname" . }}-twilio + key: number + - name: KC_SPI_PHONE_DEFAULT_TOKEN_EXPIRES_IN + value: "120" + - name: KC_SPI_PHONE_DEFAULT_YOMA_DEFAULT_NUMBER_REGEX + value: "^\\+?\\d+$" affinity: |- nodeAffinity: @@ -328,7 +357,7 @@ keycloak: admission.datadoghq.com/enabled: "false" # disabled by default (for now) podAnnotations: # gcr.io/datadoghq/dd-lib-java-init - admission.datadoghq.com/java-lib.version: v1.39.0 + admission.datadoghq.com/java-lib.version: v1.40.1 ad.datadoghq.com/keycloak.logs: '[{ "service": "keycloak", "source": "jboss_wildfly" }]' lifecycleHooks: | @@ -379,6 +408,13 @@ keycloak: http: relativePath: /auth + secrets: + twilio: + stringData: + sid: superDuperVerySecret + token: superDuperVerySecret + number: superDuperVerySecret + autoscaling: # If `true`, an autoscaling/v2 HorizontalPodAutoscaler resource is created (requires Kubernetes 1.23 or above) # Autoscaling seems to be most reliable when using KUBE_PING service discovery (see README for details) diff --git a/helm/yoma-api/conf/base/secrets.yaml b/helm/yoma-api/conf/base/secrets.yaml index 8c2a9101d..736771e3b 100644 --- a/helm/yoma-api/conf/base/secrets.yaml +++ b/helm/yoma-api/conf/base/secrets.yaml @@ -52,6 +52,7 @@ appSettings: SwaggerScopesClientCredentials: ENC[AES256_GCM,data:AJP63VIImR4=,iv:ZcC8hUkTVngm8SDFIgBvrXjpFGAsMIsvwMNdhgV+zqs=,tag:O4+saessUL9FiWSy1PPwpw==,type:str] TestDataSeedingDelayInMinutes: ENC[AES256_GCM,data:fg==,iv:6ZUIol+SIYFcqRZVyCu4WZi7IGioX8Gr9Q6ksvQHxmw=,tag:mnMF7JThkUFpQtaZfC2ZUw==,type:int] TestDataSeedingEnvironments: ENC[AES256_GCM,data:1ZBcho+GzOP3MpNsVks3FwZR,iv:CHVCYxLxWDuMFm77upO7N+IzGMxrB/HOzJXinqdM3Vw=,tag:THcAavgQrcM+IjTCIDmAfQ==,type:str] + TwilioEnabledEnvironments: null YomaOrganizationName: ENC[AES256_GCM,data:QxSwgu+4KuUdyoKz/oQlM8eIB3RdwxcqA4nX4skNcw==,iv:ybfK99GaMDcaXk1DDVnGJqzkbueCCOUvzxh12TlCQj8=,tag:gKpqMvZrxJEz5SGRDHJosw==,type:str] YomaSupportEmailAddress: ENC[AES256_GCM,data:6DSJ84zaVrBbVcNSe7Z84J6B,iv:EnrWTxwOOERulmK1TavjrpmxW4VkYahtDP1XLUsqjR0=,tag:nRTtqTZ3cWwG5aQB/Wb+wg==,type:str] Bitly: @@ -227,6 +228,12 @@ appSettings: BaseUrl: ENC[AES256_GCM,data:+o+AI3UClkY7Nr90/azY0RYy2jZ7HEntkOUrA4otyZvDnA==,iv:Fs5q0BMgdXw3S+E9O3zqMFn5Rim7R+MlSv1aF04VVVg=,tag:XplLANUsJKhGKikTXyrCcA==,type:str] ApiVersionL: ENC[AES256_GCM,data:nsVY,iv:u8KWkKJ3a677Q+jlp418LnJp8RV4plEWqfPPsOMN2LA=,tag:+OF/PGVmUVyv/bESnDkC+g==,type:str] ApiKey: ENC[AES256_GCM,data:vcDaE4jUE50=,iv:G6pRg9X9W0GoRtYFv1qZVeGSdg2pe90ixh8n7ZKNrqQ=,tag:AJkYHe4Fauvxx8REF08t6g==,type:str] + Twilio: + AccountSid: ENC[AES256_GCM,data:2JZmM7QjPqGp2ClyucnuEJgCjqRPVaVrU7SBsSMTM5VjTQ==,iv:WIOtVnOWDUD4oMYQ6u7nigYThH6wA5NTT/RzlB3tljI=,tag:JdmrGiNFheDOMxZO7jbFeg==,type:str] + AuthToken: ENC[AES256_GCM,data:dfmWnvnYxZK/NQrkvCBp3V2RzVNlD4ei3zhFqeoGonw=,iv:b9d1GXQX8VXangH4MMy4mF+M4uENeAYhZdAo8NIi8ZI=,tag:u/u8NFAuQhjTecTdS6qdEg==,type:str] + From: + SMS: ENC[AES256_GCM,data:efG0G5ZOfEL27Cc=,iv:H7rS4t4r3gyEUqzyw5mazsL2ziEECJuH6pXcqMDvFO4=,tag:FQjGjXIjSaWBvziZVgVCrw==,type:str] + Templates: null sops: kms: - arn: arn:aws:kms:eu-west-1:210913241065:alias/helm/yoma-dev @@ -241,8 +248,8 @@ sops: azure_kv: [] hc_vault: [] age: [] - lastmodified: "2024-10-24T08:24:36Z" - mac: ENC[AES256_GCM,data:sXfc3C8aGpHU38OkDNskj8hzvGOI38A/INpcAhMy4Dq7PHnhuhKImvFlGKbrb9UA+SP4dmSeHeQ1yUPMDd/z+O/m3P81d/FgXvwlo5c3i42GFbB9uH71fiI2E/WWNRSnnZOh6uLrJDVNuAFsDG+0kWlTS1XjrgrRHVrUXsEXWkI=,iv:mVpZIkuhf8RL1JHLkjZbaK8Q+4gfRzLqHJFZJFB2LUs=,tag:nv7bsyasxlfbm3emPPbV1Q==,type:str] + lastmodified: "2024-10-29T12:32:32Z" + mac: ENC[AES256_GCM,data:o+K7sitICd/AC+PpweMj84zkSIzb5mma+iFHfIaNiujmWGIh7VHC8/9E+owryRguIgyQWu5NVaLjKOJKQGM5y8eA376F/WBTl+czGhP9oe/iKNS3IJU8/NRbyeuMZZJAZFHpcV4Hcz7fdhCDlNex5mb0UxJSugavjfq0snuymuQ=,iv:Fx4qIP3ZhJRhEQeyDLScgj4kY9qXZ3bhAnegs0Y1r+c=,tag:nVVjxa6DPLdBOxTkmP6fng==,type:str] pgp: [] unencrypted_suffix: _unencrypted version: 3.9.1 diff --git a/src/api/Yoma.Core.sln b/src/api/Yoma.Core.sln index 3251973bc..ea3d5dc1f 100644 --- a/src/api/Yoma.Core.sln +++ b/src/api/Yoma.Core.sln @@ -44,6 +44,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Yoma.Core.Infrastructure.Bi EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Yoma.Core.Infrastructure.SAYouth", "src\infrastructure\Yoma.Core.Infrastructure.SAYouth\Yoma.Core.Infrastructure.SAYouth.csproj", "{A3E9E26D-ED27-4791-9A0C-5C1345FB99AD}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Yoma.Core.Infrastructure.Twilio", "src\infrastructure\Yoma.Core.Infrastructure.Twillio\Yoma.Core.Infrastructure.Twilio.csproj", "{B00B10EE-AC67-43BB-90D3-E2C1E9EBDA95}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -98,6 +100,10 @@ Global {A3E9E26D-ED27-4791-9A0C-5C1345FB99AD}.Debug|Any CPU.Build.0 = Debug|Any CPU {A3E9E26D-ED27-4791-9A0C-5C1345FB99AD}.Release|Any CPU.ActiveCfg = Release|Any CPU {A3E9E26D-ED27-4791-9A0C-5C1345FB99AD}.Release|Any CPU.Build.0 = Release|Any CPU + {B00B10EE-AC67-43BB-90D3-E2C1E9EBDA95}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B00B10EE-AC67-43BB-90D3-E2C1E9EBDA95}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B00B10EE-AC67-43BB-90D3-E2C1E9EBDA95}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B00B10EE-AC67-43BB-90D3-E2C1E9EBDA95}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -118,6 +124,7 @@ Global {4244D97D-502A-4B37-9C1A-EFD7D2DBEFDD} = {2129F035-3D60-4CC4-AE25-9BFEE9340D8D} {4988FBD6-4E1F-4830-A0EC-8975FE152FF1} = {2129F035-3D60-4CC4-AE25-9BFEE9340D8D} {A3E9E26D-ED27-4791-9A0C-5C1345FB99AD} = {2129F035-3D60-4CC4-AE25-9BFEE9340D8D} + {B00B10EE-AC67-43BB-90D3-E2C1E9EBDA95} = {2129F035-3D60-4CC4-AE25-9BFEE9340D8D} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {D658A68F-8A10-4EF2-B8C9-F1350399BF58} diff --git a/src/api/cicd/scripts/postgressql-init/post.sql b/src/api/cicd/scripts/postgressql-init/post.sql index 731fa91ea..4bb510d8a 100644 --- a/src/api/cicd/scripts/postgressql-init/post.sql +++ b/src/api/cicd/scripts/postgressql-init/post.sql @@ -10,19 +10,19 @@ SET TIMEZONE='UTC'; -- testuser@gmail.com (KeyCloak password: P@ssword1) INSERT INTO "Entity"."User"("Id", "Email", "EmailConfirmed", "FirstName", "Surname", "DisplayName", "PhoneNumber", "CountryId", "EducationId", "PhotoId", "GenderId", "DateOfBirth", "DateLastLogin", "ExternalId", "YoIDOnboarded", "DateYoIDOnboarded", "DateCreated", "DateModified") -VALUES(gen_random_uuid(), 'testuser@gmail.com', TRUE, 'Test', 'User', 'Test User', '+27125555555', (SELECT "Id" FROM "Lookup"."Country" ORDER BY RANDOM() LIMIT 1), (SELECT "Id" FROM "Lookup"."Education" ORDER BY RANDOM() LIMIT 1), +VALUES(gen_random_uuid(), 'testuser@gmail.com', TRUE, 'Test', 'User', 'Test User', NULL, (SELECT "Id" FROM "Lookup"."Country" ORDER BY RANDOM() LIMIT 1), (SELECT "Id" FROM "Lookup"."Education" ORDER BY RANDOM() LIMIT 1), NULL, (SELECT "Id" FROM "Lookup"."Gender" ORDER BY RANDOM() LIMIT 1), CURRENT_DATE - INTERVAL '20 years', NULL, NULL, TRUE, (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'), (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'), (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')); -- testadminuser@gmail.com (KeyCloak password: P@ssword1) INSERT INTO "Entity"."User"("Id", "Email", "EmailConfirmed", "FirstName", "Surname", "DisplayName", "PhoneNumber", "CountryId", "EducationId", "PhotoId", "GenderId", "DateOfBirth", "DateLastLogin", "ExternalId", "YoIDOnboarded", "DateYoIDOnboarded", "DateCreated", "DateModified") -VALUES(gen_random_uuid(), 'testadminuser@gmail.com', TRUE, 'Test Admin', 'User', 'Test Admin User', '+27125555555', (SELECT "Id" FROM "Lookup"."Country" ORDER BY RANDOM() LIMIT 1), (SELECT "Id" FROM "Lookup"."Education" ORDER BY RANDOM() LIMIT 1), +VALUES(gen_random_uuid(), 'testadminuser@gmail.com', TRUE, 'Test Admin', 'User', 'Test Admin User', NULL, (SELECT "Id" FROM "Lookup"."Country" ORDER BY RANDOM() LIMIT 1), (SELECT "Id" FROM "Lookup"."Education" ORDER BY RANDOM() LIMIT 1), NULL, (SELECT "Id" FROM "Lookup"."Gender" ORDER BY RANDOM() LIMIT 1), CURRENT_DATE - INTERVAL '21 years', NULL, NULL, TRUE, (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'), (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'), (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')); -- testorgadminuser@gmail.com (KeyCloak password: P@ssword1) INSERT INTO "Entity"."User"("Id", "Email", "EmailConfirmed", "FirstName", "Surname", "DisplayName", "PhoneNumber", "CountryId", "EducationId", "PhotoId", "GenderId", "DateOfBirth", "DateLastLogin", "ExternalId", "YoIDOnboarded", "DateYoIDOnboarded", "DateCreated", "DateModified") -VALUES(gen_random_uuid(), 'testorgadminuser@gmail.com', TRUE, 'Test Organization Admin', 'User', 'Test Organization Admin User', '+27125555555', (SELECT "Id" FROM "Lookup"."Country" ORDER BY RANDOM() LIMIT 1), (SELECT "Id" FROM "Lookup"."Education" ORDER BY RANDOM() LIMIT 1), +VALUES(gen_random_uuid(), 'testorgadminuser@gmail.com', TRUE, 'Test Organization Admin', 'User', 'Test Organization Admin User', NULL, (SELECT "Id" FROM "Lookup"."Country" ORDER BY RANDOM() LIMIT 1), (SELECT "Id" FROM "Lookup"."Education" ORDER BY RANDOM() LIMIT 1), NULL, (SELECT "Id" FROM "Lookup"."Gender" ORDER BY RANDOM() LIMIT 1), CURRENT_DATE - INTERVAL '22 years', NULL, NULL, TRUE, (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'), (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'), (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')); -- SSI Tenant Creation (Pending) for YOID onboarded users diff --git a/src/api/docker-compose.yml b/src/api/docker-compose.yml index f4c42582c..f7ca5f363 100644 --- a/src/api/docker-compose.yml +++ b/src/api/docker-compose.yml @@ -61,10 +61,12 @@ services: user: root command: | sh -c 'curl -L https://github.com/vymalo/keycloak-webhook/releases/download/v0.3.0/keycloak-webhook-0.3.0-all.jar \ - -o /opt/keycloak/providers/keycloak-webhook-0.3.0.jar && \ - chown 1000:1000 /opt/keycloak/providers/keycloak-webhook-0.3.0.jar' + -o /opt/keycloak/providers/keycloak-webhook-0.3.0.jar && \ + cp /local-providers/*.jar /opt/keycloak/providers/ && \ + chown -R 1000:1000 /opt/keycloak/providers' volumes: - keycloak:/opt/keycloak/providers + - ../keycloak/providers/jars:/local-providers # our custom JAR folder, mounted in a separate directory and copied to container keycloak-pg: image: postgres:16-alpine @@ -99,15 +101,22 @@ services: KC_HOSTNAME: keycloak KEYCLOAK_ADMIN: admin KEYCLOAK_ADMIN_PASSWORD: password + KC_LOG_LEVEL: INFO + # Custom providers + KC_SPI_PHONE_DEFAULT_SERVICE: dummy + # KC_SPI_PHONE_DEFAULT_SERVICE: twilio + # KC_SPI_MESSAGE_SENDER_SERVICE_TWILIO_ACCOUNT: your_account_sid + # KC_SPI_MESSAGE_SENDER_SERVICE_TWILIO_TOKEN: your_auth_token + # KC_SPI_MESSAGE_SENDER_SERVICE_TWILIO_NUMBER: your_number + KC_SPI_PHONE_DEFAULT_TOKEN_EXPIRES_IN: 120 # sms expires, 120 seconds + # Notice: will match after canonicalize number. eg: INTERNATIONAL: +41 44 668 18 00 , NATIONAL: 044 668 18 00 , E164: +41446681800 + KC_SPI_PHONE_DEFAULT_YOMA_DEFAULT_NUMBER_REGEX: ^\\+?\\d+$ ports: - 0.0.0.0:8080:8080 command: - - "start-dev" - - "--db=postgres" - - "--features=declarative-user-profile" - - "--spi-events-listener-jboss-logging-success-level=info" - - "--spi-events-listener-jboss-logging-error-level=warn" - # - "--log-level=DEBUG" + - start-dev + - --db=postgres + - --features=declarative-user-profile depends_on: keycloak-init: condition: service_completed_successfully @@ -115,7 +124,6 @@ services: condition: service_healthy volumes: - keycloak:/opt/keycloak/providers - - ../keycloak/themes:/opt/keycloak/themes keycloak-health: # Wait for Keycloak to be ready before running keycloak-config service image: curlimages/curl diff --git a/src/api/src/application/Yoma.Core.Api/Controllers/KeycloakController.cs b/src/api/src/application/Yoma.Core.Api/Controllers/KeycloakController.cs index ade110c61..a79faa8d5 100644 --- a/src/api/src/application/Yoma.Core.Api/Controllers/KeycloakController.cs +++ b/src/api/src/application/Yoma.Core.Api/Controllers/KeycloakController.cs @@ -1,6 +1,8 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Newtonsoft.Json.Linq; +using Yoma.Core.Domain.Core.Extensions; +using Yoma.Core.Domain.Core.Helpers; using Yoma.Core.Domain.Entity.Extensions; using Yoma.Core.Domain.Entity.Interfaces; using Yoma.Core.Domain.Entity.Models; @@ -94,18 +96,19 @@ public IActionResult ReceiveKeyCloakEvent([FromBody] JObject request) var sType = payload.Type; _logger.LogInformation("{sType} event received", sType); - Enum.TryParse(sType, true, out var type); + var type = EnumHelper.GetValueFromDescription(sType); + if (!type.HasValue) type = WebhookRequestEventType.Undefined; switch (type) { case WebhookRequestEventType.Register: case WebhookRequestEventType.UpdateProfile: case WebhookRequestEventType.Login: - _logger.LogInformation("{type} event processing", type); + _logger.LogInformation("{type} event processing", type.Value); - await UpdateUserProfile(type, payload); + await UpdateUserProfile(type.Value, payload); - _logger.LogInformation("{type} event processed", type); + _logger.LogInformation("{type} event processed", type.Value); break; default: @@ -142,7 +145,17 @@ private async Task UpdateUserProfile(WebhookRequestEventType type, KeycloakWebho return; } - var userRequest = _userService.GetByEmailOrNull(kcUser.Username, false, false)?.ToUserRequest(); + // try to locate the user based on their external Keycloak ID. + // this is the preferred lookup since users who have already completed their first login + // will have an external ID stored in the system. + var userRequest = _userService.GetByExternalIdOrNull(kcUser.Id, false, false)?.ToUserRequest(); + + // if no user is found by their external ID, fall back to locating the user by their username. + // this caters to cases where the user was created in Yoma (via B2B integration) before + // registering and completing their first login. In such cases, the external ID won't exist yet. + // also, handle test users on the development environment who might not have an associated external ID. + // a user cannot change their phone number or email address until they have completed their first login. + userRequest ??= _userService.GetByUsernameOrNull(kcUser.Username, false, false)?.ToUserRequest(); switch (type) { @@ -158,11 +171,13 @@ private async Task UpdateUserProfile(WebhookRequestEventType type, KeycloakWebho userRequest = new UserRequest(); } - userRequest.Email = kcUser.Username.Trim(); - userRequest.FirstName = kcUser.FirstName.Trim(); - userRequest.Surname = kcUser.LastName.Trim(); + userRequest.Username = kcUser.Username.Trim(); + userRequest.Email = kcUser.Email?.Trim().ToLower(); + userRequest.FirstName = kcUser.FirstName?.Trim().TitleCase(); + userRequest.Surname = kcUser.LastName?.Trim().TitleCase(); userRequest.EmailConfirmed = kcUser.EmailVerified; - userRequest.PhoneNumber = kcUser.PhoneNumber; + userRequest.PhoneNumber = kcUser.PhoneNumber?.Trim(); + userRequest.PhoneNumberConfirmed = kcUser.PhoneNumberVerified; if (!string.IsNullOrEmpty(kcUser.Country)) { @@ -212,18 +227,21 @@ private async Task UpdateUserProfile(WebhookRequestEventType type, KeycloakWebho } catch (Exception ex) { - _logger.LogError(ex, "{type}: Failed to assign the default 'User' role to the newly register user with email '{email}'", type, userRequest.Email); + _logger.LogError(ex, "{type}: Failed to assign the default 'User' role to the newly register user with username '{username}'", type, userRequest.Username); } break; case WebhookRequestEventType.Login: if (userRequest == null) { - _logger.LogError("{type}: Failed to retrieve the Yoma user with email '{email}'", type, kcUser.Username); + _logger.LogError("{type}: Failed to retrieve the Yoma user with username '{username}'", type, kcUser.Username); return; } - //after email verification a login event is raised + //after email verification, the login event is raised. + //an admin may have reverted an email update request, so ensure the email matches Keycloak, which is the source of truth. + userRequest.Username = kcUser.Username.Trim(); + userRequest.Email = kcUser.Email?.Trim().ToLower(); userRequest.EmailConfirmed = kcUser.EmailVerified; userRequest.DateLastLogin = DateTimeOffset.UtcNow; @@ -246,7 +264,7 @@ private async Task TrackLogin(KeycloakWebhookRequest payload, UserRequest userRe { try { - _logger.LogInformation("Tracking login for user with email '{email}'", userRequest.Email); + _logger.LogInformation("Tracking login for user with username '{username}'", userRequest.Username); await _userService.TrackLogin(new UserRequestLoginEvent { UserId = userRequest.Id, @@ -256,11 +274,11 @@ await _userService.TrackLogin(new UserRequestLoginEvent AuthType = payload.Details?.Auth_type }); - _logger.LogInformation("Tracked login for user with email '{email}'", userRequest.Email); + _logger.LogInformation("Tracked login for user with username '{username}'", userRequest.Username); } catch (Exception ex) { - _logger.LogError(ex, "Failed to track login for user with email '{email}'", userRequest.Email); + _logger.LogError(ex, "Failed to track login for user with username '{username}'", userRequest.Username); } } @@ -268,13 +286,13 @@ private async Task CreateWalletOrScheduleCreation(UserRequest userRequest) { try { - _logger.LogInformation("Creating or scheduling creation of rewards wallet for user with '{email}'", userRequest.Email); + _logger.LogInformation("Creating or scheduling creation (create or update username) of rewards wallet for user with username '{username}'", userRequest.Username); await _walletService.CreateWalletOrScheduleCreation(userRequest.Id); - _logger.LogInformation("Rewards wallet created or creation scheduled for user with '{email}'", userRequest.Email); + _logger.LogInformation("Rewards wallet created or creation scheduled (create or update username) for user with username '{username}'", userRequest.Username); } catch (Exception ex) { - _logger.LogError(ex, "Failed to create or schedule creation of rewards wallet for user with username '{email}'", userRequest.Email); + _logger.LogError(ex, "Failed to create or schedule creation (create or update username) of rewards wallet for user with username '{username}'", userRequest.Username); } } } diff --git a/src/api/src/application/Yoma.Core.Api/Controllers/LookupController.cs b/src/api/src/application/Yoma.Core.Api/Controllers/LookupController.cs index 410c92228..95dea10bd 100644 --- a/src/api/src/application/Yoma.Core.Api/Controllers/LookupController.cs +++ b/src/api/src/application/Yoma.Core.Api/Controllers/LookupController.cs @@ -33,8 +33,7 @@ public LookupController( IGenderService genderService, ILanguageService languageService, ISkillService skillService, - ITimeIntervalService timeIntervalService - ) + ITimeIntervalService timeIntervalService) { _logger = logger; _countryService = countryService; diff --git a/src/api/src/application/Yoma.Core.Api/Controllers/OrganizationController.cs b/src/api/src/application/Yoma.Core.Api/Controllers/OrganizationController.cs index 3563fbf17..ed9673e5e 100644 --- a/src/api/src/application/Yoma.Core.Api/Controllers/OrganizationController.cs +++ b/src/api/src/application/Yoma.Core.Api/Controllers/OrganizationController.cs @@ -247,11 +247,11 @@ public async Task DeleteDocuments([FromRoute] Guid id, [FromRoute [HttpPatch("{id}/assign/admins")] [ProducesResponseType(typeof(Organization), (int)HttpStatusCode.OK)] [Authorize(Roles = $"{Constants.Role_Admin}, {Constants.Role_OrganizationAdmin}")] - public async Task AssignAdmins([FromRoute] Guid id, [FromRoute] List emails) + public async Task AssignAdmins([FromRoute] Guid id, [FromRoute] List usernames) { _logger.LogInformation("Handling request {requestName}", nameof(AssignAdmins)); - var result = await _organizationService.AssignAdmins(id, emails, true); + var result = await _organizationService.AssignAdmins(id, usernames, true); _logger.LogInformation("Request {requestName} handled", nameof(AssignAdmins)); @@ -262,11 +262,11 @@ public async Task AssignAdmins([FromRoute] Guid id, [FromRoute] L [HttpPatch("{id}/remove/admins")] [ProducesResponseType(typeof(Organization), (int)HttpStatusCode.OK)] [Authorize(Roles = $"{Constants.Role_Admin}, {Constants.Role_OrganizationAdmin}")] - public async Task RemoveAdmins([FromRoute] Guid id, [FromRoute] List emails) + public async Task RemoveAdmins([FromRoute] Guid id, [FromRoute] List usernames) { _logger.LogInformation("Handling request {requestName}", nameof(RemoveAdmins)); - var result = await _organizationService.RemoveAdmins(id, emails, true); + var result = await _organizationService.RemoveAdmins(id, usernames, true); _logger.LogInformation("Request {requestName} handled", nameof(RemoveAdmins)); diff --git a/src/api/src/application/Yoma.Core.Api/Startup.cs b/src/api/src/application/Yoma.Core.Api/Startup.cs index d08cdc5cf..923e34408 100644 --- a/src/api/src/application/Yoma.Core.Api/Startup.cs +++ b/src/api/src/application/Yoma.Core.Api/Startup.cs @@ -26,6 +26,7 @@ using Yoma.Core.Infrastructure.Emsi; using Yoma.Core.Infrastructure.Keycloak; using Yoma.Core.Infrastructure.SendGrid; +using Yoma.Core.Infrastructure.Twilio; using Yoma.Core.Infrastructure.Zlto; using Yoma.Core.Infrastructure.SAYouth; using Yoma.Core.Domain.PartnerSharing.Interfaces.Provider; @@ -82,6 +83,7 @@ public void ConfigureServices(IServiceCollection services) services.ConfigureServices_LaborMarketProvider(_configuration); services.ConfigureServices_IdentityProvider(_configuration); services.ConfigureServices_EmailProvider(_configuration); + services.ConfigureServices_MessageProvider(_configuration); services.ConfigureServices_RewardProvider(_configuration); services.ConfigureServices_SharingProvider(_configuration); #endregion Configuration @@ -128,6 +130,7 @@ public void ConfigureServices(IServiceCollection services) services.ConfigureServices_InfrastructureIdentityProvider(); services.ConfigureServices_InfrastructureSharingProvider(); services.ConfigureServices_InfrastructureEmailProvider(_configuration); + services.ConfigureServices_InfrastructureMessageProvider(_configuration); services.ConfigureServices_InfrastructureRewardProvider(); services.ConfigureServices_DomainServicesCompositionFactory(sp => diff --git a/src/api/src/application/Yoma.Core.Api/Yoma.Core.Api.csproj b/src/api/src/application/Yoma.Core.Api/Yoma.Core.Api.csproj index 8f6dc811e..b3caf3cfb 100644 --- a/src/api/src/application/Yoma.Core.Api/Yoma.Core.Api.csproj +++ b/src/api/src/application/Yoma.Core.Api/Yoma.Core.Api.csproj @@ -38,6 +38,7 @@ + diff --git a/src/api/src/application/Yoma.Core.Api/appsettings.json b/src/api/src/application/Yoma.Core.Api/appsettings.json index d4f4655c0..dce0ea4b9 100644 --- a/src/api/src/application/Yoma.Core.Api/appsettings.json +++ b/src/api/src/application/Yoma.Core.Api/appsettings.json @@ -48,6 +48,7 @@ "TestDataSeedingEnvironments": "Local, Development, Staging", "TestDataSeedingDelayInMinutes": 5, "SendGridEnabledEnvironments": "Staging, Production", + "TwilioEnabledEnvironments": null, "SentryEnabledEnvironments": "Development, Staging, Production", "HttpsRedirectionEnabledEnvironments": null, "LaborMarketProviderAsSourceEnabledEnvironments": "Production", @@ -209,6 +210,13 @@ } }, + "Twilio": { + "AccountSid": "[accountSid]", + "AuthToken": "[authToken]", + "From": null, + "Templates": null + }, + "Zlto": { "Username": "[partner_username]", "Password": "[partner_password]", diff --git a/src/api/src/domain/Yoma.Core.Domain/ActionLink/Services/LinkService.cs b/src/api/src/domain/Yoma.Core.Domain/ActionLink/Services/LinkService.cs index 5ad50fab0..1a0224674 100644 --- a/src/api/src/domain/Yoma.Core.Domain/ActionLink/Services/LinkService.cs +++ b/src/api/src/domain/Yoma.Core.Domain/ActionLink/Services/LinkService.cs @@ -17,9 +17,9 @@ using Yoma.Core.Domain.Core.Helpers; using Yoma.Core.Domain.Core.Interfaces; using Yoma.Core.Domain.Core.Models; -using Yoma.Core.Domain.EmailProvider; -using Yoma.Core.Domain.EmailProvider.Interfaces; -using Yoma.Core.Domain.EmailProvider.Models; +using Yoma.Core.Domain.Notification; +using Yoma.Core.Domain.Notification.Interfaces; +using Yoma.Core.Domain.Notification.Models; using Yoma.Core.Domain.Entity.Interfaces; using Yoma.Core.Domain.Entity.Models; using Yoma.Core.Domain.IdentityProvider.Extensions; @@ -46,9 +46,8 @@ public class LinkService : ILinkService private readonly IRepositoryBatched _linkRepository; private readonly IRepository _linkUsageLogRepository; private readonly IExecutionStrategyService _executionStrategyService; - private readonly IEmailProviderClient _emailProviderClient; - private readonly IEmailURLFactory _emailURLFactory; - private readonly IEmailPreferenceFilterService _emailPreferenceFilterService; + private readonly INotificationDeliveryService _notificationDeliveryService; + private readonly INotificationURLFactory _notificationURLFactory; private readonly IIdentityProviderClient _identityProviderClient; private readonly LinkRequestCreateValidatorShare _linkRequestCreateValidatorShare; @@ -75,9 +74,8 @@ public LinkService(ILogger logger, IRepositoryBatched linkRepository, IRepository linkUsageLogRepository, IExecutionStrategyService executionStrategyService, - IEmailProviderClientFactory emailProviderClientFactory, - IEmailURLFactory emailURLFactory, - IEmailPreferenceFilterService emailPreferenceFilterService, + INotificationDeliveryService notificationDeliveryService, + INotificationURLFactory notificationURLFactory, IIdentityProviderClientFactory identityProviderClientFactory, LinkRequestCreateValidatorShare linkRequestCreateValidatorShare, LinkRequestCreateValidatorVerify linkRequestCreateValidatorVerify, @@ -95,9 +93,8 @@ public LinkService(ILogger logger, _linkRepository = linkRepository; _linkUsageLogRepository = linkUsageLogRepository; _executionStrategyService = executionStrategyService; - _emailProviderClient = emailProviderClientFactory.CreateClient(); - _emailURLFactory = emailURLFactory; - _emailPreferenceFilterService = emailPreferenceFilterService; + _notificationDeliveryService = notificationDeliveryService; + _notificationURLFactory = notificationURLFactory; _identityProviderClient = identityProviderClientFactory.CreateClient(); _linkRequestCreateValidatorShare = linkRequestCreateValidatorShare; _linkRequestCreateValidatorVerify = linkRequestCreateValidatorVerify; @@ -288,7 +285,7 @@ public async Task CreateVerify(LinkRequestCreateVerify request, bool e throw new InvalidOperationException($"Invalid / unsupported entity type of '{request.EntityType}'"); } - await SendEmail(item, EmailType.ActionLink_Verify_Approval_Requested); + await SendNotification(item, NotificationType.ActionLink_Verify_Approval_Requested); return item.ToLinkInfo(request.IncludeQRCode); } @@ -311,8 +308,8 @@ public async Task UpdateStatus(Guid id, LinkRequestUpdateStatus reques var action = Enum.Parse(link.Action); - EmailActionLinkVerify? emailDataDistributionList = null; - EmailType? emailType = null; + NotificationActionLinkVerify? notificationlDataDistributionList = null; + NotificationType? notificationType = null; switch (action) { case LinkAction.Share: @@ -350,13 +347,13 @@ public async Task UpdateStatus(Guid id, LinkRequestUpdateStatus reques if (!opportunity.Published) throw new ValidationException($"Link cannot be activated as the opportunity '{opportunity.Title}' has not been published"); - emailDataDistributionList = new EmailActionLinkVerify + notificationlDataDistributionList = new NotificationActionLinkVerify { EntityTypeDesc = $"{entityType.ToString().ToLower()}(ies)", - YoIDURL = _emailURLFactory.OpportunityVerificationYoIDURL(EmailType.ActionLink_Verify_Distribution), + YoIDURL = _notificationURLFactory.OpportunityVerificationYoIDURL(NotificationType.ActionLink_Verify_Distribution), Items = [ - new EmailActionLinkVerifyItem + new NotificationActionLinkVerifyItem { Title = opportunity.Title, DateStart = opportunity.DateStart, @@ -374,7 +371,7 @@ public async Task UpdateStatus(Guid id, LinkRequestUpdateStatus reques throw new InvalidOperationException($"Invalid / unsupported entity type of '{entityType}'"); } - emailType = EmailType.ActionLink_Verify_Approval_Approved; + notificationType = NotificationType.ActionLink_Verify_Approval_Approved; break; case LinkStatus.Inactive: @@ -385,7 +382,7 @@ public async Task UpdateStatus(Guid id, LinkRequestUpdateStatus reques if (!HttpContextAccessorHelper.IsAdminRole(_httpContextAccessor)) throw new SecurityException("Unauthorized"); - emailType = EmailType.ActionLink_Verify_Approval_Requested; + notificationType = NotificationType.ActionLink_Verify_Approval_Requested; break; case LinkStatus.Declined: @@ -398,7 +395,7 @@ public async Task UpdateStatus(Guid id, LinkRequestUpdateStatus reques link.CommentApproval = request.Comment; - emailType = EmailType.ActionLink_Verify_Approval_Declined; + notificationType = NotificationType.ActionLink_Verify_Approval_Declined; break; case LinkStatus.Deleted: @@ -422,8 +419,8 @@ public async Task UpdateStatus(Guid id, LinkRequestUpdateStatus reques link = await _linkRepository.Update(link); - if (emailDataDistributionList != null) await SendEmail_ActionLinkVerifyDistributionList(link, emailDataDistributionList); - if (emailType.HasValue) await SendEmail(link, emailType.Value); + if (notificationlDataDistributionList != null) await SendNotification_ActionLinkVerifyDistributionList(link, notificationlDataDistributionList); + if (notificationType.HasValue) await SendNotification(link, notificationType.Value); return link.ToLinkInfo(false); } @@ -501,7 +498,7 @@ private Opportunity.Models.Opportunity ValidateAndInitializeOpportunity(LinkRequ private Link LinkFromRequest(LinkRequestCreateBase request, LinkStatus status, bool ensureOrganizationAuthorization) { - var user = _userService.GetByEmail(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, !ensureOrganizationAuthorization), false, false); + var user = _userService.GetByUsername(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, !ensureOrganizationAuthorization), false, false); var item = new Link { @@ -547,7 +544,7 @@ private async Task LogUsage(Link link) //user context optional; only tracked provided executed with context if (!HttpContextAccessorHelper.UserContextAvailable(_httpContextAccessor)) return link.ToLinkInfo(false); - user = _userService.GetByEmail(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, false), false, false); + user = _userService.GetByUsername(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, false), false, false); usageLog = _linkUsageLogRepository.Query().SingleOrDefault(o => o.LinkId == link.Id && o.UserId == user.Id); if (usageLog != null) return link.ToLinkInfo(false); @@ -557,7 +554,7 @@ private async Task LogUsage(Link link) //user context required if (!HttpContextAccessorHelper.UserContextAvailable(_httpContextAccessor)) throw new InvalidOperationException($"User context required for link with action '{action}'"); - user = _userService.GetByEmail(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, false), false, false); + user = _userService.GetByUsername(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, false), false, false); usageLog = _linkUsageLogRepository.Query().SingleOrDefault(o => o.LinkId == link.Id && o.UserId == user.Id); if (usageLog != null) throw new ValidationException($"This link has already been used / claimed on '{usageLog.DateCreated:yyyy-MM-dd HH:mm:ss}'"); @@ -573,12 +570,15 @@ private async Task LogUsage(Link link) if (link.DistributionList == null) throw new DataInconsistencyException("Link is locked to a distribution list but no distribution list is defined"); - var emails = JsonConvert.DeserializeObject>(link.DistributionList); + var distributionList = JsonConvert.DeserializeObject>(link.DistributionList); - if (emails == null || emails.Count == 0) + if (distributionList == null || distributionList.Count == 0) throw new DataInconsistencyException("Link is locked to a distribution list but no distribution list is defined"); - if (!emails.Contains(user.Email, StringComparer.InvariantCultureIgnoreCase)) + var isAuthorized = (!string.IsNullOrEmpty(user.Email) && distributionList.Contains(user.Email, StringComparer.InvariantCultureIgnoreCase)) || + (!string.IsNullOrEmpty(user.PhoneNumber) && distributionList.Contains(user.PhoneNumber)); + + if (!isAuthorized) throw new SecurityException("Unauthorized: You don't have access because this link is limited to specific users"); } @@ -606,45 +606,55 @@ await _executionStrategyService.ExecuteInExecutionStrategyAsync(async () => return link.ToLinkInfo(false); } - private async Task SendEmail_ActionLinkVerifyDistributionList(Link link, EmailActionLinkVerify emailData) + private async Task SendNotification_ActionLinkVerifyDistributionList(Link link, NotificationActionLinkVerify data) { var distributionList = string.IsNullOrEmpty(link.DistributionList) ? null : JsonConvert.DeserializeObject>(link.DistributionList); if (distributionList == null) return; try { - var recipients = distributionList.Select(o => new EmailRecipient { Email = o }).ToList(); - await _emailProviderClient.Send(EmailType.ActionLink_Verify_Distribution, recipients, emailData); - _logger.LogInformation("Successfully send email"); + var recipients = distributionList.Select(item => + { + var isEmail = item.Contains('@'); + return new NotificationRecipient + { + Email = isEmail ? item : null, + PhoneNumber = isEmail ? null : item, + Username = item + }; + }).ToList(); + + await _notificationDeliveryService.Send(NotificationType.ActionLink_Verify_Distribution, recipients, data); + _logger.LogInformation("Successfully send notification"); } catch (Exception ex) { - _logger.LogError(ex, "Failed to send email"); + _logger.LogError(ex, "Failed to send notification"); } } - private async Task SendEmail(Link link, EmailType type) + private async Task SendNotification(Link link, NotificationType type) { try { - List? recipients = null; + List? recipients = null; - var dataLink = new EmailActionLinkVerifyApprovalItem { Name = link.Name, EntityType = link.EntityType }; + var dataLink = new NotificationActionLinkVerifyApprovalItem { Name = link.Name, EntityType = link.EntityType }; switch (type) { - case EmailType.ActionLink_Verify_Approval_Requested: - //send email to super administrators + case NotificationType.ActionLink_Verify_Approval_Requested: + //send notification to super administrators var superAdmins = await _identityProviderClient.ListByRole(Constants.Role_Admin); - recipients = superAdmins?.Select(o => new EmailRecipient { Email = o.Email, DisplayName = o.ToDisplayName() }).ToList(); + recipients = superAdmins?.Select(o => new NotificationRecipient { Username = o.Username, PhoneNumber = o.PhoneNumber, Email = o.Email, DisplayName = o.ToDisplayName() ?? o.Username }).ToList(); dataLink.Comment = link.CommentApproval; - dataLink.URL = _emailURLFactory.ActionLinkVerifyApprovalItemUrl(type, null); + dataLink.URL = _notificationURLFactory.ActionLinkVerifyApprovalItemUrl(type, null); break; - case EmailType.ActionLink_Verify_Approval_Approved: - case EmailType.ActionLink_Verify_Approval_Declined: + case NotificationType.ActionLink_Verify_Approval_Approved: + case NotificationType.ActionLink_Verify_Approval_Declined: var entityType = Enum.Parse(link.EntityType, true); switch (entityType) { @@ -652,13 +662,13 @@ private async Task SendEmail(Link link, EmailType type) if (!link.OpportunityOrganizationId.HasValue) throw new InvalidOperationException("Opportunity organization details expected"); - //send email to organization administrators + //send notification to organization administrators var organization = _organizationService.GetById(link.OpportunityOrganizationId.Value, true, false, false); - recipients = organization.Administrators?.Select(o => new EmailRecipient { Email = o.Email, DisplayName = o.DisplayName }).ToList(); + recipients = organization.Administrators?.Select(o => new NotificationRecipient { Username = o.Username, PhoneNumber = o.PhoneNumber, Email = o.Email, DisplayName = o.DisplayName }).ToList(); dataLink.Comment = link.CommentApproval; - dataLink.URL = _emailURLFactory.ActionLinkVerifyApprovalItemUrl(type, organization.Id); + dataLink.URL = _notificationURLFactory.ActionLinkVerifyApprovalItemUrl(type, organization.Id); break; default: @@ -671,22 +681,19 @@ private async Task SendEmail(Link link, EmailType type) throw new ArgumentOutOfRangeException(nameof(type), $"Type of '{type}' not supported"); } - recipients = _emailPreferenceFilterService.FilterRecipients(type, recipients); - if (recipients == null || recipients.Count == 0) return; - - var data = new EmailActionLinkVerifyApproval + var data = new NotificationActionLinkVerifyApproval { Links = [dataLink] }; - await _emailProviderClient.Send(type, recipients, data); + await _notificationDeliveryService.Send(type, recipients, data); - _logger.LogInformation("Successfully send email"); + _logger.LogInformation("Successfully send notification"); } catch (Exception ex) { - _logger.LogError(ex, "Failed to send email"); + _logger.LogError(ex, "Failed to send notification"); } } #endregion diff --git a/src/api/src/domain/Yoma.Core.Domain/ActionLink/Services/LinkServiceBackgroundService.cs b/src/api/src/domain/Yoma.Core.Domain/ActionLink/Services/LinkServiceBackgroundService.cs index dde9a8dda..093f6bf35 100644 --- a/src/api/src/domain/Yoma.Core.Domain/ActionLink/Services/LinkServiceBackgroundService.cs +++ b/src/api/src/domain/Yoma.Core.Domain/ActionLink/Services/LinkServiceBackgroundService.cs @@ -8,9 +8,9 @@ using Yoma.Core.Domain.Core.Helpers; using Yoma.Core.Domain.Core.Interfaces; using Yoma.Core.Domain.Core.Models; -using Yoma.Core.Domain.EmailProvider; -using Yoma.Core.Domain.EmailProvider.Interfaces; -using Yoma.Core.Domain.EmailProvider.Models; +using Yoma.Core.Domain.Notification; +using Yoma.Core.Domain.Notification.Interfaces; +using Yoma.Core.Domain.Notification.Models; using Yoma.Core.Domain.Entity.Interfaces; namespace Yoma.Core.Domain.ActionLink.Services @@ -23,9 +23,8 @@ public class LinkServiceBackgroundService : ILinkServiceBackgroundService private readonly ILinkStatusService _linkStatusService; private readonly IUserService _userService; private readonly IOrganizationService _organizationService; - private readonly IEmailProviderClient _emailProviderClient; - private readonly IEmailURLFactory _emailURLFactory; - private readonly IEmailPreferenceFilterService _emailPreferenceFilterService; + private readonly INotificationDeliveryService _notificationDeliveryService; + private readonly INotificationURLFactory _notificationURLFactory; private readonly IRepositoryBatched _linkRepository; private readonly IDistributedLockService _distributedLockService; @@ -40,9 +39,8 @@ public LinkServiceBackgroundService(ILogger logger ILinkStatusService linkStatusService, IUserService userService, IOrganizationService organizationService, - IEmailProviderClientFactory emailProviderClientFactory, - IEmailURLFactory emailURLFactory, - IEmailPreferenceFilterService emailPreferenceFilterService, + INotificationDeliveryService notificationDeliveryService, + INotificationURLFactory notificationURLFactory, IRepositoryBatched linkRepository, IDistributedLockService distributedLockService) { @@ -51,9 +49,8 @@ public LinkServiceBackgroundService(ILogger logger _linkStatusService = linkStatusService; _userService = userService; _organizationService = organizationService; - _emailProviderClient = emailProviderClientFactory.CreateClient(); - _emailURLFactory = emailURLFactory; - _emailPreferenceFilterService = emailPreferenceFilterService; + _notificationDeliveryService = notificationDeliveryService; + _notificationURLFactory = notificationURLFactory; _linkRepository = linkRepository; _distributedLockService = distributedLockService; } @@ -92,7 +89,7 @@ public async Task ProcessExpiration() o.DateEnd.HasValue && o.DateEnd.Value <= DateTimeOffset.UtcNow).OrderBy(o => o.DateEnd).Take(_scheduleJobOptions.ActionLinkExpirationScheduleBatchSize).ToList(); if (items.Count == 0) break; - var user = _userService.GetByEmail(HttpContextAccessorHelper.GetUsernameSystem, false, false); + var user = _userService.GetByUsername(HttpContextAccessorHelper.GetUsernameSystem, false, false); foreach (var item in items) { @@ -156,7 +153,7 @@ public async Task ProcessDeclination() .OrderBy(o => o.DateModified).Take(_scheduleJobOptions.ActionLinkDeclinationScheduleBatchSize).ToList(); if (items.Count == 0) break; - var user = _userService.GetByEmail(HttpContextAccessorHelper.GetUsernameSystem, false, false); + var user = _userService.GetByUsername(HttpContextAccessorHelper.GetUsernameSystem, false, false); foreach (var item in items) { @@ -169,13 +166,13 @@ public async Task ProcessDeclination() items = await _linkRepository.Update(items); - var emailType = EmailType.ActionLink_Verify_Approval_Declined; - List? recipients = null; + var notificationType = NotificationType.ActionLink_Verify_Approval_Declined; + List? recipients = null; foreach (var item in items) { try { - var dataLink = new EmailActionLinkVerifyApprovalItem { Name = item.Name, EntityType = item.EntityType }; + var dataLink = new NotificationActionLinkVerifyApprovalItem { Name = item.Name, EntityType = item.EntityType }; var entityType = Enum.Parse(item.EntityType, true); switch (entityType) @@ -184,16 +181,13 @@ public async Task ProcessDeclination() if (!item.OpportunityOrganizationId.HasValue) throw new InvalidOperationException("Opportunity organization details expected"); - //send email to organization administrators + //send notification to organization administrators var organization = _organizationService.GetById(item.OpportunityOrganizationId.Value, true, false, false); - recipients = organization.Administrators?.Select(o => new EmailRecipient { Email = o.Email, DisplayName = o.DisplayName }).ToList(); - - recipients = _emailPreferenceFilterService.FilterRecipients(emailType, recipients); - if (recipients == null || recipients.Count == 0) continue; + recipients = organization.Administrators?.Select(o => new NotificationRecipient { Username = o.Username, PhoneNumber = o.PhoneNumber, Email = o.Email, DisplayName = o.DisplayName }).ToList(); dataLink.Comment = item.CommentApproval; - dataLink.URL = _emailURLFactory.ActionLinkVerifyApprovalItemUrl(emailType, organization.Id); + dataLink.URL = _notificationURLFactory.ActionLinkVerifyApprovalItemUrl(notificationType, organization.Id); break; @@ -201,18 +195,18 @@ public async Task ProcessDeclination() throw new InvalidOperationException($"Invalid / unsupported entity type of '{entityType}'"); } - var data = new EmailActionLinkVerifyApproval + var data = new NotificationActionLinkVerifyApproval { Links = [dataLink] }; - await _emailProviderClient.Send(emailType, recipients, data); + await _notificationDeliveryService.Send(notificationType, recipients, data); - _logger.LogInformation("Successfully send email"); + _logger.LogInformation("Successfully send notification"); } catch (Exception ex) { - _logger.LogError(ex, "Failed to send email"); + _logger.LogError(ex, "Failed to send notification"); } } @@ -268,7 +262,7 @@ public async Task ProcessDeletion() .OrderBy(o => o.DateModified).Take(_scheduleJobOptions.ActionLinkDeletionScheduleBatchSize).ToList(); if (items.Count == 0) break; - var user = _userService.GetByEmail(HttpContextAccessorHelper.GetUsernameSystem, false, false); + var user = _userService.GetByUsername(HttpContextAccessorHelper.GetUsernameSystem, false, false); foreach (var item in items) { diff --git a/src/api/src/domain/Yoma.Core.Domain/ActionLink/Validators/LinkRequestCreateValidatorVerify.cs b/src/api/src/domain/Yoma.Core.Domain/ActionLink/Validators/LinkRequestCreateValidatorVerify.cs index fcd9e94d6..f57e14117 100644 --- a/src/api/src/domain/Yoma.Core.Domain/ActionLink/Validators/LinkRequestCreateValidatorVerify.cs +++ b/src/api/src/domain/Yoma.Core.Domain/ActionLink/Validators/LinkRequestCreateValidatorVerify.cs @@ -1,6 +1,8 @@ using FluentValidation; +using System.ComponentModel.DataAnnotations; using Yoma.Core.Domain.ActionLink.Models; using Yoma.Core.Domain.Core.Extensions; +using Yoma.Core.Domain.Core.Validators; namespace Yoma.Core.Domain.ActionLink.Validators { @@ -28,9 +30,11 @@ public LinkRequestCreateValidatorVerify() .DependentRules(() => { RuleForEach(x => x.DistributionList) - .NotEmpty() - .EmailAddress() - .WithMessage("'Distribution List' contain(s) empty or invalid email address(es)."); + .NotEmpty() + .Must(item => + new EmailAddressAttribute().IsValid(item) || + RegExValidators.PhoneNumber().IsMatch(item)) + .WithMessage("'Distribution List' contain(s) empty, invalid email address(es) or phone number(s)."); }); RuleFor(x => x.DateEnd).Must(date => !date.HasValue || date.Value.ToEndOfDay() > DateTimeOffset.UtcNow).WithMessage("'{PropertyName}' must be in the future."); diff --git a/src/api/src/domain/Yoma.Core.Domain/Core/Constants.cs b/src/api/src/domain/Yoma.Core.Domain/Core/Constants.cs index 5e4ef9667..0c4469ce1 100644 --- a/src/api/src/domain/Yoma.Core.Domain/Core/Constants.cs +++ b/src/api/src/domain/Yoma.Core.Domain/Core/Constants.cs @@ -8,7 +8,8 @@ public static class Constants public static readonly string[] Roles_Supported = [Role_User, Role_Admin, Role_OrganizationAdmin]; public const string ClaimType_Role = "role"; - internal const string ModifiedBy_System_Username = "system@yoma.world"; + internal const string System_Domain = "yoma.world"; + internal const string System_Username_ModifiedBy = $"system@{System_Domain}"; internal const int TimeIntervalSummary_Data_MaxNoOfPoints = 52; } } diff --git a/src/api/src/domain/Yoma.Core.Domain/Core/Extensions/EnumExtensions.cs b/src/api/src/domain/Yoma.Core.Domain/Core/Extensions/EnumExtensions.cs index 9f687f9ce..9e1c08b0c 100644 --- a/src/api/src/domain/Yoma.Core.Domain/Core/Extensions/EnumExtensions.cs +++ b/src/api/src/domain/Yoma.Core.Domain/Core/Extensions/EnumExtensions.cs @@ -1,5 +1,4 @@ using System.ComponentModel; -using System.Reflection; using System.Runtime.Serialization; namespace Yoma.Core.Domain.Core.Extensions @@ -34,24 +33,5 @@ public static string ToDescription(this Enum value) return attrib?.Description ?? value.ToString(); } - - public static string? GetValueFromEnumMemberValue(string value) - where T : Enum - { - var type = typeof(T); - if (type.GetTypeInfo().IsEnum) - { - foreach (var name in Enum.GetNames(type)) - { - var attr = type.GetRuntimeField(name)?.GetCustomAttribute(true); - if (attr != null && attr.Value == value) - return Enum.Parse(type, name, true).ToString(); - } - - return null; - } - - throw new InvalidOperationException("Not Enum"); - } } } diff --git a/src/api/src/domain/Yoma.Core.Domain/Core/Helpers/EnumHelper.cs b/src/api/src/domain/Yoma.Core.Domain/Core/Helpers/EnumHelper.cs new file mode 100644 index 000000000..00dc11f55 --- /dev/null +++ b/src/api/src/domain/Yoma.Core.Domain/Core/Helpers/EnumHelper.cs @@ -0,0 +1,46 @@ +using System.ComponentModel; +using System.Reflection; +using System.Runtime.Serialization; + +namespace Yoma.Core.Domain.Core.Helpers +{ + public static class EnumHelper + { + public static T? GetValueFromEnumMemberValue(string value) where T : struct, Enum + { + ArgumentException.ThrowIfNullOrWhiteSpace(value, nameof(value)); + value = value.Trim(); + + var type = typeof(T); + foreach (var name in Enum.GetNames(type)) + { + var attr = type.GetRuntimeField(name)?.GetCustomAttribute(true); + if (attr != null && string.Equals(attr.Value, value, StringComparison.InvariantCultureIgnoreCase)) + return (T)Enum.Parse(type, name, true); + } + + return null; + } + + public static T? GetValueFromDescription(string value) where T : struct, Enum + { + ArgumentException.ThrowIfNullOrWhiteSpace(value, nameof(value)); + value = value.Trim(); + + var type = typeof(T); + foreach (var field in type.GetFields()) + { + var attribute = field.GetCustomAttribute(); + var attrDescription = attribute?.Description; + + if (!string.IsNullOrEmpty(attrDescription) && + string.Equals(attrDescription, value, StringComparison.InvariantCultureIgnoreCase)) + { + return (T)Enum.Parse(type, field.Name); + } + } + + return null; + } + } +} diff --git a/src/api/src/domain/Yoma.Core.Domain/Core/Helpers/HttpContextAccessorHelper.cs b/src/api/src/domain/Yoma.Core.Domain/Core/Helpers/HttpContextAccessorHelper.cs index f74dc18f1..34573d664 100644 --- a/src/api/src/domain/Yoma.Core.Domain/Core/Helpers/HttpContextAccessorHelper.cs +++ b/src/api/src/domain/Yoma.Core.Domain/Core/Helpers/HttpContextAccessorHelper.cs @@ -6,7 +6,7 @@ namespace Yoma.Core.Domain.Core.Helpers { public static class HttpContextAccessorHelper { - public static string GetUsernameSystem => Constants.ModifiedBy_System_Username; + public static string GetUsernameSystem => Constants.System_Username_ModifiedBy; public static HashSet DefinedRoles => [Constants.Role_User, Constants.Role_Admin, Constants.Role_OrganizationAdmin]; @@ -25,7 +25,7 @@ public static string GetUsername(IHttpContextAccessor? httpContextAccessor, bool if (string.IsNullOrEmpty(result)) { if (!useSystemDefault) throw new SecurityException("Unauthorized: User context not available"); - result = Constants.ModifiedBy_System_Username; + result = Constants.System_Username_ModifiedBy; } return result; diff --git a/src/api/src/domain/Yoma.Core.Domain/Core/Models/AppSettings.cs b/src/api/src/domain/Yoma.Core.Domain/Core/Models/AppSettings.cs index 124d4fd33..f218727bb 100644 --- a/src/api/src/domain/Yoma.Core.Domain/Core/Models/AppSettings.cs +++ b/src/api/src/domain/Yoma.Core.Domain/Core/Models/AppSettings.cs @@ -81,6 +81,10 @@ public CacheItemType CacheEnabledByCacheItemTypesAsEnum public Environment SentryEnabledEnvironmentsAsEnum => ParseEnvironmentInput(SentryEnabledEnvironments); + public string TwilioEnabledEnvironments { get; set; } + + public Environment TwilioEnabledEnvironmentsAsEnum => ParseEnvironmentInput(TwilioEnabledEnvironments); + public string HttpsRedirectionEnabledEnvironments { get; set; } public Environment HttpsRedirectionEnabledEnvironmentsAsEnum => ParseEnvironmentInput(HttpsRedirectionEnabledEnvironments); diff --git a/src/api/src/domain/Yoma.Core.Domain/Core/Validators/RegExValidators.cs b/src/api/src/domain/Yoma.Core.Domain/Core/Validators/RegExValidators.cs index acf297375..f9602aad9 100644 --- a/src/api/src/domain/Yoma.Core.Domain/Core/Validators/RegExValidators.cs +++ b/src/api/src/domain/Yoma.Core.Domain/Core/Validators/RegExValidators.cs @@ -4,7 +4,8 @@ namespace Yoma.Core.Domain.Core.Validators { public static partial class RegExValidators { - [GeneratedRegex("^[\\+]?[(]?[0-9]{3}[)]?[-\\s\\.]?[0-9]{3}[-\\s\\.]?[0-9]{4,6}$")] + //[GeneratedRegex("^[\\+]?[(]?[0-9]{3}[)]?[-\\s\\.]?[0-9]{3}[-\\s\\.]?[0-9]{4,6}$")] + [GeneratedRegex("^\\+?\\d+$")] public static partial Regex PhoneNumber(); [GeneratedRegex("[^a-zA-Z0-9 -]")] diff --git a/src/api/src/domain/Yoma.Core.Domain/EmailProvider/Interfaces/IEmailPreferenceFilterService.cs b/src/api/src/domain/Yoma.Core.Domain/EmailProvider/Interfaces/IEmailPreferenceFilterService.cs deleted file mode 100644 index 66ab9b200..000000000 --- a/src/api/src/domain/Yoma.Core.Domain/EmailProvider/Interfaces/IEmailPreferenceFilterService.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Yoma.Core.Domain.EmailProvider.Models; - -namespace Yoma.Core.Domain.EmailProvider.Interfaces -{ - public interface IEmailPreferenceFilterService - { - List? FilterRecipients(EmailType type, List? recipients); - } -} diff --git a/src/api/src/domain/Yoma.Core.Domain/EmailProvider/Interfaces/IEmailProviderClient.cs b/src/api/src/domain/Yoma.Core.Domain/EmailProvider/Interfaces/IEmailProviderClient.cs deleted file mode 100644 index d9aa6bd48..000000000 --- a/src/api/src/domain/Yoma.Core.Domain/EmailProvider/Interfaces/IEmailProviderClient.cs +++ /dev/null @@ -1,13 +0,0 @@ -using Yoma.Core.Domain.EmailProvider.Models; - -namespace Yoma.Core.Domain.EmailProvider.Interfaces -{ - public interface IEmailProviderClient - { - Task Send(EmailType type, List recipients, T data) - where T : EmailBase; - - Task Send(EmailType type, List<(List Recipients, T Data)> recipientDataGroups) - where T : EmailBase; - } -} diff --git a/src/api/src/domain/Yoma.Core.Domain/EmailProvider/Interfaces/IEmailURLFactory.cs b/src/api/src/domain/Yoma.Core.Domain/EmailProvider/Interfaces/IEmailURLFactory.cs deleted file mode 100644 index a8de9c2d3..000000000 --- a/src/api/src/domain/Yoma.Core.Domain/EmailProvider/Interfaces/IEmailURLFactory.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace Yoma.Core.Domain.EmailProvider.Interfaces -{ - public interface IEmailURLFactory - { - string OrganizationApprovalItemURL(EmailType emailType, Guid organizationId); - - string OpportunityVerificationItemURL(EmailType emailType, Guid opportunityId, Guid? organizationId); - - string? OpportunityVerificationYoIDURL(EmailType emailType); - - string? OpportunityVerificationURL(EmailType emailType, Guid organizationId); - - string OpportunityExpirationItemURL(EmailType emailType, Guid opportunityId, Guid organizationId); - - string OpportunityAnnouncedItemURL(EmailType emailType, Guid opportunityId, Guid organizationId); - - string ActionLinkVerifyApprovalItemUrl(EmailType emailType, Guid? organizationId); - } -} diff --git a/src/api/src/domain/Yoma.Core.Domain/EmailProvider/Models/EmailRecipient.cs b/src/api/src/domain/Yoma.Core.Domain/EmailProvider/Models/EmailRecipient.cs deleted file mode 100644 index 2a5714aee..000000000 --- a/src/api/src/domain/Yoma.Core.Domain/EmailProvider/Models/EmailRecipient.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Yoma.Core.Domain.EmailProvider.Models -{ - public class EmailRecipient - { - public string Email { get; set; } - - public string? DisplayName { get; set; } - } -} diff --git a/src/api/src/domain/Yoma.Core.Domain/EmailProvider/Services/EmailPreferenceFilterService.cs b/src/api/src/domain/Yoma.Core.Domain/EmailProvider/Services/EmailPreferenceFilterService.cs deleted file mode 100644 index 9103d497c..000000000 --- a/src/api/src/domain/Yoma.Core.Domain/EmailProvider/Services/EmailPreferenceFilterService.cs +++ /dev/null @@ -1,69 +0,0 @@ -using Microsoft.Extensions.Logging; -using Yoma.Core.Domain.EmailProvider.Interfaces; -using Yoma.Core.Domain.EmailProvider.Models; -using Yoma.Core.Domain.Entity; -using Yoma.Core.Domain.Entity.Helpers; -using Yoma.Core.Domain.Entity.Interfaces; - -namespace Yoma.Core.Domain.EmailProvider.Services -{ - public class EmailPreferenceFilterService : IEmailPreferenceFilterService - { - #region Class Variables - private readonly ILogger _logger; - private readonly IUserService _userService; - #endregion - - #region Constructor - public EmailPreferenceFilterService(IUserService userService, ILogger logger) - { - _userService = userService; - _logger = logger; - } - #endregion - - #region Public Members - public List? FilterRecipients(EmailType type, List? recipients) - { - if (recipients == null || recipients.Count == 0) return recipients; - - var setting = type switch - { - // user - EmailType.Opportunity_Verification_Rejected or EmailType.Opportunity_Verification_Completed or EmailType.Opportunity_Verification_Pending => Setting.User_Email_Opportunity_Completion, - EmailType.Opportunity_Published => Setting.User_Email_Opportunity_Published, - // organization admin - EmailType.Organization_Approval_Approved or EmailType.Organization_Approval_Declined => Setting.Organization_Admin_Email_Organization_Approval, - EmailType.Opportunity_Expiration_Expired or EmailType.Opportunity_Expiration_WithinNextDays => Setting.Organization_Admin_Email_Opportunity_Expiration, - EmailType.Opportunity_Verification_Pending_Admin => Setting.Organization_Admin_Email_Opportunity_Completion, - EmailType.ActionLink_Verify_Approval_Approved or EmailType.ActionLink_Verify_Approval_Declined => Setting.Organization_Admin_Email_ActionLink_Verify_Approval, - // admin - EmailType.Organization_Approval_Requested => Setting.Admin_Email_Organization_Approval, - EmailType.Opportunity_Posted_Admin => Setting.Admin_Email_Opportunity_Posted, - EmailType.ActionLink_Verify_Approval_Requested => Setting.Admin_Email_ActionLink_Verify_Approval, - _ => throw new ArgumentOutOfRangeException(nameof(type), $"Type of '{type}' not supported"), - }; - var result = new List(); - - foreach (var recipient in recipients) - { - try - { - var settingsInfo = _userService.GetSettingsInfoByEmail(recipient.Email); - var settingValue = SettingsHelper.GetValue(settingsInfo, setting.ToString()); - - if (settingValue == true) - result.Add(recipient); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to evaluate recipient email preference"); - result.Add(recipient); - } - } - - return result; - } - #endregion - } -} diff --git a/src/api/src/domain/Yoma.Core.Domain/Entity/Enumerations.cs b/src/api/src/domain/Yoma.Core.Domain/Entity/Enumerations.cs index 53d39bf82..ae707f665 100644 --- a/src/api/src/domain/Yoma.Core.Domain/Entity/Enumerations.cs +++ b/src/api/src/domain/Yoma.Core.Domain/Entity/Enumerations.cs @@ -31,7 +31,7 @@ internal enum OrganizationReapprovalAction { None, Reapproval, - ReapprovalWithEmail + ReapprovalWithNotification } public enum SettingType { @@ -42,16 +42,16 @@ public enum SettingType public enum Setting { - User_Email_Opportunity_Published, - User_Email_Opportunity_Completion, - Organization_Admin_Email_Opportunity_Expiration, - Organization_Admin_Email_Organization_Approval, - Organization_Admin_Email_Opportunity_Completion, - Organization_Admin_Email_ActionLink_Verify_Approval, - Admin_Email_Opportunity_Posted, - Admin_Email_Organization_Approval, - Admin_Email_ActionLink_Verify_Approval, - User_Share_Email_With_Partners, + User_Notification_Opportunity_Published, + User_Notification_Opportunity_Completion, + Organization_Admin_Notification_Opportunity_Expiration, + Organization_Admin_Notification_Organization_Approval, + Organization_Admin_Notification_Opportunity_Completion, + Organization_Admin_Notification_ActionLink_Verify_Approval, + Admin_Notification_Opportunity_Posted, + Admin_Notification_Organization_Approval, + Admin_Notification_ActionLink_Verify_Approval, + User_Share_Contact_Info_With_Partners, Organization_Share_Address_Details_With_Partners, Organization_Share_Contact_Info_With_Partners } diff --git a/src/api/src/domain/Yoma.Core.Domain/Entity/Extensions/UserExtensions.cs b/src/api/src/domain/Yoma.Core.Domain/Entity/Extensions/UserExtensions.cs index 04a7257c4..d14a475a9 100644 --- a/src/api/src/domain/Yoma.Core.Domain/Entity/Extensions/UserExtensions.cs +++ b/src/api/src/domain/Yoma.Core.Domain/Entity/Extensions/UserExtensions.cs @@ -10,6 +10,7 @@ public static void SetDisplayName(this User user) if (!string.IsNullOrEmpty(user.DisplayName)) return; user.DisplayName = string.Join(' ', new[] { user.FirstName, user.Surname }.Where(o => !string.IsNullOrEmpty(o))); + if (string.IsNullOrEmpty(user.DisplayName)) user.DisplayName = null; } public static void SetDisplayName(this UserRequest user) @@ -18,6 +19,7 @@ public static void SetDisplayName(this UserRequest user) if (!string.IsNullOrEmpty(user.DisplayName)) return; user.DisplayName = string.Join(' ', new[] { user.FirstName, user.Surname }.Where(o => !string.IsNullOrEmpty(o))); + if (string.IsNullOrEmpty(user.DisplayName)) user.DisplayName = null; } public static UserRequest ToUserRequest(this User user) @@ -27,12 +29,14 @@ public static UserRequest ToUserRequest(this User user) return new UserRequest { Id = user.Id, + Username = user.Username, Email = user.Email, EmailConfirmed = user.EmailConfirmed, FirstName = user.FirstName, Surname = user.Surname, - DisplayName = user.DisplayName, + DisplayName = user.DisplayName, //cannot default; used to update user PhoneNumber = user.PhoneNumber, + PhoneNumberConfirmed = user.PhoneNumberConfirmed, CountryId = user.CountryId, EducationId = user.EducationId, GenderId = user.GenderId, @@ -49,10 +53,12 @@ public static UserInfo ToInfo(this User value) return new UserInfo { Id = value.Id, + Username = value.Username, Email = value.Email, FirstName = value.FirstName, Surname = value.Surname, - DisplayName = value.DisplayName, + DisplayName = value.DisplayName ?? value.Username, + PhoneNumber = value.PhoneNumber, CountryId = value.CountryId }; } @@ -64,12 +70,14 @@ public static UserProfile ToProfile(this User value) return new UserProfile { Id = value.Id, + Username = value.Username, Email = value.Email, EmailConfirmed = value.EmailConfirmed, FirstName = value.FirstName, Surname = value.Surname, - DisplayName = value.DisplayName, + DisplayName = value.DisplayName, //cannot default; model returned on api for editing as authenticated user (simular to user returned on api as admin) PhoneNumber = value.PhoneNumber, + PhoneNumberConfirmed = value.PhoneNumberConfirmed, CountryId = value.CountryId, EducationId = value.EducationId, GenderId = value.GenderId, diff --git a/src/api/src/domain/Yoma.Core.Domain/Entity/Interfaces/IOrganizationService.cs b/src/api/src/domain/Yoma.Core.Domain/Entity/Interfaces/IOrganizationService.cs index cb84f427b..775bea59a 100644 --- a/src/api/src/domain/Yoma.Core.Domain/Entity/Interfaces/IOrganizationService.cs +++ b/src/api/src/domain/Yoma.Core.Domain/Entity/Interfaces/IOrganizationService.cs @@ -37,9 +37,9 @@ public interface IOrganizationService Task DeleteDocuments(Guid id, OrganizationDocumentType type, List documentsIds, bool ensureOrganizationAuthorization); - Task AssignAdmins(Guid id, List emails, bool ensureOrganizationAuthorization); + Task AssignAdmins(Guid id, List usernames, bool ensureOrganizationAuthorization); - Task RemoveAdmins(Guid id, List emails, bool ensureOrganizationAuthorization); + Task RemoveAdmins(Guid id, List usernames, bool ensureOrganizationAuthorization); Task AllocateRewards(Organization organization, decimal? zltoReward, decimal? yomaReward); diff --git a/src/api/src/domain/Yoma.Core.Domain/Entity/Interfaces/IUserService.cs b/src/api/src/domain/Yoma.Core.Domain/Entity/Interfaces/IUserService.cs index ed283c09c..ef6d6dd24 100644 --- a/src/api/src/domain/Yoma.Core.Domain/Entity/Interfaces/IUserService.cs +++ b/src/api/src/domain/Yoma.Core.Domain/Entity/Interfaces/IUserService.cs @@ -5,17 +5,23 @@ namespace Yoma.Core.Domain.Entity.Interfaces { public interface IUserService { - User GetByEmail(string? email, bool includeChildItems, bool includeComputed); + User GetByUsername(string username, bool includeChildItems, bool includeComputed); - User? GetByEmailOrNull(string email, bool includeChildItems, bool includeComputed); + User? GetByUsernameOrNull(string? username, bool includeChildItems, bool includeComputed); + + User? GetByEmailOrNull(string? email, bool includeChildItems, bool includeComputed); + + User? GetByPhoneOrNull(string? phoneNumber, bool includeChildItems, bool includeComputed); + + User? GetByExternalIdOrNull(Guid externalId, bool includeChildItems, bool includeComputed); User GetById(Guid Id, bool includeChildItems, bool includeComputed); User? GetByIdOrNull(Guid id, bool includeChildItems, bool includeComputed); - Settings GetSettingsByEmail(string email); + Settings GetSettingsByUsername(string username); - SettingsInfo GetSettingsInfoByEmail(string email); + SettingsInfo GetSettingsInfoByUsername(string username); SettingsInfo GetSettingsInfoById(Guid id); @@ -27,13 +33,13 @@ public interface IUserService Task Upsert(UserRequest request); - Task UpsertPhoto(string? email, IFormFile? file); + Task UpsertPhoto(string username, IFormFile? file); - Task UpdateSettings(string? email, List roles, SettingsRequest request); + Task UpdateSettings(string username, List roles, SettingsRequest request); Task AssignSkills(User user, Opportunity.Models.Opportunity opportunity); - Task YoIDOnboard(string? email); + Task YoIDOnboard(string username); Task TrackLogin(UserRequestLoginEvent request); } diff --git a/src/api/src/domain/Yoma.Core.Domain/Entity/Models/OrganizationRequestBase.cs b/src/api/src/domain/Yoma.Core.Domain/Entity/Models/OrganizationRequestBase.cs index 4712f2d6e..168c57e96 100644 --- a/src/api/src/domain/Yoma.Core.Domain/Entity/Models/OrganizationRequestBase.cs +++ b/src/api/src/domain/Yoma.Core.Domain/Entity/Models/OrganizationRequestBase.cs @@ -58,7 +58,7 @@ public abstract class OrganizationRequestBase [Required] public bool AddCurrentUserAsAdmin { get; set; } - public List? AdminEmails { get; set; } + public List? Admins { get; set; } /// /// Outbound SSO Client ID used for configuring SSO, allowing logins on third-party systems using Yoma credentials diff --git a/src/api/src/domain/Yoma.Core.Domain/Entity/Models/User.cs b/src/api/src/domain/Yoma.Core.Domain/Entity/Models/User.cs index f06f355b8..b96318555 100644 --- a/src/api/src/domain/Yoma.Core.Domain/Entity/Models/User.cs +++ b/src/api/src/domain/Yoma.Core.Domain/Entity/Models/User.cs @@ -7,18 +7,22 @@ public class User { public Guid Id { get; set; } - public string Email { get; set; } + public string Username { get; set; } - public bool EmailConfirmed { get; set; } + public string? Email { get; set; } - public string FirstName { get; set; } + public bool? EmailConfirmed { get; set; } - public string Surname { get; set; } + public string? FirstName { get; set; } - public string DisplayName { get; set; } + public string? Surname { get; set; } + + public string? DisplayName { get; set; } public string? PhoneNumber { get; set; } + public bool? PhoneNumberConfirmed { get; set; } + public Guid? CountryId { get; set; } public string? Country { get; set; } diff --git a/src/api/src/domain/Yoma.Core.Domain/Entity/Models/UserInfo.cs b/src/api/src/domain/Yoma.Core.Domain/Entity/Models/UserInfo.cs index 2ed3d041e..2003a11f5 100644 --- a/src/api/src/domain/Yoma.Core.Domain/Entity/Models/UserInfo.cs +++ b/src/api/src/domain/Yoma.Core.Domain/Entity/Models/UserInfo.cs @@ -4,14 +4,18 @@ public class UserInfo { public Guid Id { get; set; } - public string Email { get; set; } + public string Username { get; set; } - public string FirstName { get; set; } + public string? Email { get; set; } - public string Surname { get; set; } + public string? FirstName { get; set; } + + public string? Surname { get; set; } public string? DisplayName { get; set; } + public string? PhoneNumber { get; set; } + public Guid? CountryId { get; set; } public override bool Equals(object? obj) diff --git a/src/api/src/domain/Yoma.Core.Domain/Entity/Models/UserProfile.cs b/src/api/src/domain/Yoma.Core.Domain/Entity/Models/UserProfile.cs index ed015beca..197b425cd 100644 --- a/src/api/src/domain/Yoma.Core.Domain/Entity/Models/UserProfile.cs +++ b/src/api/src/domain/Yoma.Core.Domain/Entity/Models/UserProfile.cs @@ -4,18 +4,22 @@ public class UserProfile { public Guid Id { get; set; } - public string Email { get; set; } + public string Username { get; set; } - public bool EmailConfirmed { get; set; } + public string? Email { get; set; } - public string FirstName { get; set; } + public bool? EmailConfirmed { get; set; } - public string Surname { get; set; } + public string? FirstName { get; set; } + + public string? Surname { get; set; } public string? DisplayName { get; set; } public string? PhoneNumber { get; set; } + public bool? PhoneNumberConfirmed { get; set; } + public Guid? CountryId { get; set; } public Guid? EducationId { get; set; } diff --git a/src/api/src/domain/Yoma.Core.Domain/Entity/Models/UserRequest.cs b/src/api/src/domain/Yoma.Core.Domain/Entity/Models/UserRequest.cs index 208abb522..b7307934f 100644 --- a/src/api/src/domain/Yoma.Core.Domain/Entity/Models/UserRequest.cs +++ b/src/api/src/domain/Yoma.Core.Domain/Entity/Models/UserRequest.cs @@ -4,7 +4,17 @@ public class UserRequest : UserRequestBase { public Guid? Id { get; set; } - public bool EmailConfirmed { get; set; } + public string Username { get; set; } + + public string? FirstName { get; set; } + + public string? Surname { get; set; } + + public string? PhoneNumber { get; set; } + + public bool? EmailConfirmed { get; set; } + + public bool? PhoneNumberConfirmed { get; set; } public DateTimeOffset? DateLastLogin { get; set; } diff --git a/src/api/src/domain/Yoma.Core.Domain/Entity/Models/UserRequestBase.cs b/src/api/src/domain/Yoma.Core.Domain/Entity/Models/UserRequestBase.cs index 5e4b69315..9aa61e12c 100644 --- a/src/api/src/domain/Yoma.Core.Domain/Entity/Models/UserRequestBase.cs +++ b/src/api/src/domain/Yoma.Core.Domain/Entity/Models/UserRequestBase.cs @@ -2,16 +2,10 @@ namespace Yoma.Core.Domain.Entity.Models { public abstract class UserRequestBase { - public string Email { get; set; } - - public string FirstName { get; set; } - - public string Surname { get; set; } + public string? Email { get; set; } public string? DisplayName { get; set; } - public string? PhoneNumber { get; set; } - public Guid? CountryId { get; set; } public Guid? EducationId { get; set; } diff --git a/src/api/src/domain/Yoma.Core.Domain/Entity/Models/UserRequestProfile.cs b/src/api/src/domain/Yoma.Core.Domain/Entity/Models/UserRequestProfile.cs index 8368cfd30..73fcb92ae 100644 --- a/src/api/src/domain/Yoma.Core.Domain/Entity/Models/UserRequestProfile.cs +++ b/src/api/src/domain/Yoma.Core.Domain/Entity/Models/UserRequestProfile.cs @@ -2,6 +2,12 @@ namespace Yoma.Core.Domain.Entity.Models { public class UserRequestProfile : UserRequestBase { + public string FirstName { get; set; } + + public string Surname { get; set; } + + public bool UpdatePhoneNumber { get; set; } + public bool ResetPassword { get; set; } } } diff --git a/src/api/src/domain/Yoma.Core.Domain/Entity/Services/OrganizationBackgroundService.cs b/src/api/src/domain/Yoma.Core.Domain/Entity/Services/OrganizationBackgroundService.cs index 0d37b1019..591ac14e9 100644 --- a/src/api/src/domain/Yoma.Core.Domain/Entity/Services/OrganizationBackgroundService.cs +++ b/src/api/src/domain/Yoma.Core.Domain/Entity/Services/OrganizationBackgroundService.cs @@ -7,8 +7,8 @@ using Yoma.Core.Domain.Core.Helpers; using Yoma.Core.Domain.Core.Interfaces; using Yoma.Core.Domain.Core.Models; -using Yoma.Core.Domain.EmailProvider.Interfaces; -using Yoma.Core.Domain.EmailProvider.Models; +using Yoma.Core.Domain.Notification.Interfaces; +using Yoma.Core.Domain.Notification.Models; using Yoma.Core.Domain.Entity.Events; using Yoma.Core.Domain.Entity.Interfaces; using Yoma.Core.Domain.Entity.Interfaces.Lookups; @@ -25,10 +25,9 @@ public class OrganizationBackgroundService : IOrganizationBackgroundService private readonly IEnvironmentProvider _environmentProvider; private readonly IOrganizationService _organizationService; private readonly IOrganizationStatusService _organizationStatusService; - private readonly IEmailProviderClient _emailProviderClient; + private readonly INotificationDeliveryService _notificationDeliveryService; private readonly IUserService _userService; - private readonly IEmailURLFactory _emailURLFactory; - private readonly IEmailPreferenceFilterService _emailPreferenceFilterService; + private readonly INotificationURLFactory _notificationURLFactory; private readonly IRepositoryBatchedValueContainsWithNavigation _organizationRepository; private readonly IRepository _organizationDocumentRepository; private readonly IDistributedLockService _distributedLockService; @@ -44,10 +43,9 @@ public OrganizationBackgroundService(ILogger logg IEnvironmentProvider environmentProvider, IOrganizationService organizationService, IOrganizationStatusService organizationStatusService, - IEmailProviderClientFactory emailProviderClientFactory, + INotificationDeliveryService notificationDeliveryService, IUserService userService, - IEmailURLFactory emailURLFactory, - IEmailPreferenceFilterService emailPreferenceFilterService, + INotificationURLFactory notificationURLFactory, IRepositoryBatchedValueContainsWithNavigation organizationRepository, IRepository organizationDocumentRepository, IDistributedLockService distributedLockService, @@ -59,10 +57,9 @@ public OrganizationBackgroundService(ILogger logg _environmentProvider = environmentProvider; _organizationService = organizationService; _organizationStatusService = organizationStatusService; - _emailProviderClient = emailProviderClientFactory.CreateClient(); + _notificationDeliveryService = notificationDeliveryService; _userService = userService; - _emailURLFactory = emailURLFactory; - _emailPreferenceFilterService = emailPreferenceFilterService; + _notificationURLFactory = notificationURLFactory; _organizationRepository = organizationRepository; _organizationDocumentRepository = organizationDocumentRepository; _distributedLockService = distributedLockService; @@ -103,7 +100,7 @@ public async Task ProcessDeclination() .OrderBy(o => o.DateModified).Take(_scheduleJobOptions.OrganizationDeclinationBatchSize).ToList(); if (items.Count == 0) break; - var user = _userService.GetByEmail(HttpContextAccessorHelper.GetUsernameSystem, false, false); + var user = _userService.GetByUsername(HttpContextAccessorHelper.GetUsernameSystem, false, false); foreach (var item in items) { @@ -123,36 +120,33 @@ public async Task ProcessDeclination() .SelectMany(org => org.Administrators ?? Enumerable.Empty(), (org, admin) => new { Administrator = admin, Organization = org }) .GroupBy(item => item.Administrator, item => item.Organization); - var emailType = EmailProvider.EmailType.Organization_Approval_Declined; + var notificationType = Notification.NotificationType.Organization_Approval_Declined; foreach (var group in groupedOrganizations) { try { - var recipients = new List + var recipients = new List { - new() { Email = group.Key.Email, DisplayName = group.Key.DisplayName } + new() { Username = group.Key.Username, PhoneNumber = group.Key.PhoneNumber, Email = group.Key.Email, DisplayName = group.Key.DisplayName } }; - recipients = _emailPreferenceFilterService.FilterRecipients(emailType, recipients); - if (recipients == null || recipients.Count == 0) continue; - - var data = new EmailOrganizationApproval + var data = new NotificationOrganizationApproval { - Organizations = group.Select(org => new EmailOrganizationApprovalItem + Organizations = group.Select(org => new NotificationOrganizationApprovalItem { Name = org.Name, Comment = org.CommentApproval, - URL = _emailURLFactory.OrganizationApprovalItemURL(emailType, org.Id) + URL = _notificationURLFactory.OrganizationApprovalItemURL(notificationType, org.Id) }).ToList() }; - await _emailProviderClient.Send(emailType, recipients, data); + await _notificationDeliveryService.Send(notificationType, recipients, data); - _logger.LogInformation("Successfully send email"); + _logger.LogInformation("Successfully send notification"); } catch (Exception ex) { - _logger.LogError(ex, "Failed to send email"); + _logger.LogError(ex, "Failed to send notification"); } } @@ -208,7 +202,7 @@ public async Task ProcessDeletion() .OrderBy(o => o.DateModified).Take(_scheduleJobOptions.OrganizationDeletionBatchSize).ToList(); if (items.Count == 0) break; - var user = _userService.GetByEmail(HttpContextAccessorHelper.GetUsernameSystem, false, false); + var user = _userService.GetByUsername(HttpContextAccessorHelper.GetUsernameSystem, false, false); foreach (var item in items) { diff --git a/src/api/src/domain/Yoma.Core.Domain/Entity/Services/OrganizationService.cs b/src/api/src/domain/Yoma.Core.Domain/Entity/Services/OrganizationService.cs index 5b09f4b26..e70290c8f 100644 --- a/src/api/src/domain/Yoma.Core.Domain/Entity/Services/OrganizationService.cs +++ b/src/api/src/domain/Yoma.Core.Domain/Entity/Services/OrganizationService.cs @@ -12,9 +12,9 @@ using Yoma.Core.Domain.Core.Helpers; using Yoma.Core.Domain.Core.Interfaces; using Yoma.Core.Domain.Core.Models; -using Yoma.Core.Domain.EmailProvider; -using Yoma.Core.Domain.EmailProvider.Interfaces; -using Yoma.Core.Domain.EmailProvider.Models; +using Yoma.Core.Domain.Notification; +using Yoma.Core.Domain.Notification.Interfaces; +using Yoma.Core.Domain.Notification.Models; using Yoma.Core.Domain.Entity.Events; using Yoma.Core.Domain.Entity.Extensions; using Yoma.Core.Domain.Entity.Helpers; @@ -41,9 +41,8 @@ public class OrganizationService : IOrganizationService private readonly IOrganizationProviderTypeService _providerTypeService; private readonly IBlobService _blobService; private readonly ISSITenantService _ssiTenantService; - private readonly IEmailURLFactory _emailURLFactory; - private readonly IEmailPreferenceFilterService _emailPreferenceFilterService; - private readonly IEmailProviderClient _emailProviderClient; + private readonly INotificationURLFactory _notificationURLFactory; + private readonly INotificationDeliveryService _notificationDeliveryService; private readonly ISettingsDefinitionService _settingsDefinitionService; private readonly OrganizationRequestValidatorCreate _organizationCreateRequestValidator; private readonly OrganizationRequestValidatorUpdate _organizationUpdateRequestValidator; @@ -76,9 +75,8 @@ public OrganizationService(ILogger logger, IOrganizationProviderTypeService providerTypeService, IBlobService blobService, ISSITenantService ssiTenantService, - IEmailURLFactory emailURLFactory, - IEmailPreferenceFilterService emailPreferenceFilterService, - IEmailProviderClientFactory emailProviderClientFactory, + INotificationURLFactory notificationURLFactory, + INotificationDeliveryService notificationDeliveryService, ISettingsDefinitionService settingsDefinitionService, OrganizationRequestValidatorCreate organizationCreateRequestValidator, OrganizationRequestValidatorUpdate organizationUpdateRequestValidator, @@ -101,9 +99,8 @@ public OrganizationService(ILogger logger, _providerTypeService = providerTypeService; _blobService = blobService; _ssiTenantService = ssiTenantService; - _emailURLFactory = emailURLFactory; - _emailPreferenceFilterService = emailPreferenceFilterService; - _emailProviderClient = emailProviderClientFactory.CreateClient(); + _notificationURLFactory = notificationURLFactory; + _notificationDeliveryService = notificationDeliveryService; _settingsDefinitionService = settingsDefinitionService; _organizationCreateRequestValidator = organizationCreateRequestValidator; _organizationUpdateRequestValidator = organizationUpdateRequestValidator; @@ -283,7 +280,7 @@ public async Task Create(OrganizationRequestCreate request) if (rewardPoolsSpecified && !HttpContextAccessorHelper.IsAdminRole(_httpContextAccessor)) throw new SecurityException("Unauthorized"); - var user = _userService.GetByEmail(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, false), false, false); + var user = _userService.GetByUsername(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, false), false, false); var result = new Organization { @@ -338,9 +335,9 @@ await _executionStrategyService.ExecuteInExecutionStrategyAsync(async () => } //assign admins - var admins = request.AdminEmails ??= []; + var admins = request.Admins ??= []; if (request.AddCurrentUserAsAdmin) - admins.Add(user.Email); + admins.Add(user.Username); else if (HttpContextAccessorHelper.IsUserRoleOnly(_httpContextAccessor)) throw new ValidationException($"The registering user must be added as an organization admin by default ('{nameof(request.AddCurrentUserAsAdmin)}' must be true)"); result = await AssignAdmins(result, admins, OrganizationReapprovalAction.None); @@ -384,7 +381,7 @@ await _executionStrategyService.ExecuteInExecutionStrategyAsync(async () => throw; } - await SendEmail(result, EmailType.Organization_Approval_Requested); + await SendNotification(result, NotificationType.Organization_Approval_Requested); return result; } @@ -431,7 +428,7 @@ public async Task Update(OrganizationRequestUpdate request, bool e if (request.YomaRewardPool.HasValue && result.YomaRewardCumulative.HasValue && request.YomaRewardPool.Value < result.YomaRewardCumulative.Value) throw new ValidationException($"The Yoma reward pool cannot be less than the cumulative Yoma rewards ({result.YomaRewardCumulative.Value:F2}) already allocated to participants"); - var user = _userService.GetByEmail(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, !ensureOrganizationAuthorization), false, false); + var user = _userService.GetByUsername(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, !ensureOrganizationAuthorization), false, false); result.Name = request.Name.NormalizeTrim(); result.WebsiteURL = request.WebsiteURL?.ToLower(); @@ -482,10 +479,10 @@ await _executionStrategyService.ExecuteInExecutionStrategyAsync(async () => } //admins - var admins = request.AdminEmails ??= []; + var admins = request.Admins ??= []; if (request.AddCurrentUserAsAdmin) - admins.Add(user.Email); - result = await RemoveAdmins(result, result.Administrators?.Where(o => !admins.Contains(o.Email)).Select(o => o.Email).ToList(), OrganizationReapprovalAction.None); + admins.Add(user.Username); + result = await RemoveAdmins(result, result.Administrators?.Where(o => !string.IsNullOrEmpty(o.Username) && !admins.Contains(o.Username)).Select(o => o.Username).ToList(), OrganizationReapprovalAction.None); result = await AssignAdmins(result, admins, OrganizationReapprovalAction.None); //documents @@ -562,7 +559,7 @@ await _executionStrategyService.ExecuteInExecutionStrategyAsync(async () => } if (statusCurrent != OrganizationStatus.Inactive && result.Status == OrganizationStatus.Inactive) - await SendEmail(result, EmailType.Organization_Approval_Requested); + await SendNotification(result, NotificationType.Organization_Approval_Requested); if (statusCurrent != result.Status) await _mediator.Publish(new OrganizationStatusChangedEvent(result)); @@ -578,9 +575,9 @@ public async Task UpdateStatus(Guid id, OrganizationRequestUpdateS var result = GetById(id, true, true, ensureOrganizationAuthorization); - var user = _userService.GetByEmail(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, !ensureOrganizationAuthorization), false, false); + var user = _userService.GetByUsername(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, !ensureOrganizationAuthorization), false, false); - EmailType? emailType = null; + NotificationType? notificationType = null; await _executionStrategyService.ExecuteInExecutionStrategyAsync(async () => { using var scope = new TransactionScope(TransactionScopeOption.RequiresNew, TransactionScopeAsyncFlowOption.Enabled); @@ -602,7 +599,7 @@ await _executionStrategyService.ExecuteInExecutionStrategyAsync(async () => await _ssiTenantService.ScheduleCreation(EntityType.Organization, result.Id); - emailType = EmailType.Organization_Approval_Approved; + notificationType = NotificationType.Organization_Approval_Approved; break; case OrganizationStatus.Inactive: @@ -613,7 +610,7 @@ await _executionStrategyService.ExecuteInExecutionStrategyAsync(async () => if (!HttpContextAccessorHelper.IsAdminRole(_httpContextAccessor)) throw new SecurityException("Unauthorized"); - emailType = EmailType.Organization_Approval_Requested; + notificationType = NotificationType.Organization_Approval_Requested; break; case OrganizationStatus.Declined: @@ -626,7 +623,7 @@ await _executionStrategyService.ExecuteInExecutionStrategyAsync(async () => result.CommentApproval = request.Comment; - emailType = EmailType.Organization_Approval_Declined; + notificationType = NotificationType.Organization_Approval_Declined; break; case OrganizationStatus.Deleted: @@ -651,7 +648,7 @@ await _executionStrategyService.ExecuteInExecutionStrategyAsync(async () => scope.Complete(); }); - if (emailType.HasValue) await SendEmail(result, emailType.Value); + if (notificationType.HasValue) await SendNotification(result, notificationType.Value); await _mediator.Publish(new OrganizationStatusChangedEvent(result)); @@ -698,14 +695,14 @@ public async Task AssignProviderTypes(Guid id, List provider if (isProviderTypeMarketplace && (result.Documents == null || !result.Documents.Where(o => o.Type == OrganizationDocumentType.Business).Any())) throw new ValidationException($"Business documents are required. Add the required documents before assigning the provider type"); - var user = _userService.GetByEmail(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, !ensureOrganizationAuthorization), false, false); + var user = _userService.GetByUsername(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, !ensureOrganizationAuthorization), false, false); var statusCurrent = result.Status; await _executionStrategyService.ExecuteInExecutionStrategyAsync(async () => { using var scope = new TransactionScope(TransactionScopeOption.RequiresNew, TransactionScopeAsyncFlowOption.Enabled); - result = await AssignProviderTypes(result, providerTypeIds, OrganizationReapprovalAction.ReapprovalWithEmail); + result = await AssignProviderTypes(result, providerTypeIds, OrganizationReapprovalAction.ReapprovalWithNotification); result.ModifiedByUserId = user.Id; result = await _organizationRepository.Update(result); scope.Complete(); @@ -729,14 +726,14 @@ public async Task RemoveProviderTypes(Guid id, List provider if (result.ProviderTypes == null || result.ProviderTypes.All(o => providerTypeIds.Contains(o.Id))) throw new ValidationException("One or more provider types are required. Removal will result in no associated provider types"); - var user = _userService.GetByEmail(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, !ensureOrganizationAuthorization), false, false); + var user = _userService.GetByUsername(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, !ensureOrganizationAuthorization), false, false); var statusCurrent = result.Status; await _executionStrategyService.ExecuteInExecutionStrategyAsync(async () => { using var scope = new TransactionScope(TransactionScopeOption.RequiresNew, TransactionScopeAsyncFlowOption.Enabled); - result = await RemoveProviderTypes(result, providerTypeIds, OrganizationReapprovalAction.ReapprovalWithEmail); + result = await RemoveProviderTypes(result, providerTypeIds, OrganizationReapprovalAction.ReapprovalWithNotification); result.ModifiedByUserId = user.Id; result = await _organizationRepository.Update(result); scope.Complete(); @@ -760,7 +757,7 @@ public async Task UpdateLogo(Guid id, IFormFile? file, bool ensure ValidateUpdatable(result); - var user = _userService.GetByEmail(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, !ensureOrganizationAuthorization), false, false); + var user = _userService.GetByUsername(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, !ensureOrganizationAuthorization), false, false); // only allow a user to add a logo if: they are in a user-only role, the organization has no logo, they are the original creator, and the organization is inactive. if (userRoleOnly && (result.LogoId.HasValue || user.Id != result.CreatedByUserId || result.Status != OrganizationStatus.Inactive)) @@ -772,7 +769,7 @@ public async Task UpdateLogo(Guid id, IFormFile? file, bool ensure await _executionStrategyService.ExecuteInExecutionStrategyAsync(async () => { using var scope = new TransactionScope(TransactionScopeOption.RequiresNew, TransactionScopeAsyncFlowOption.Enabled); - resultLogo = await UpdateLogo(result, file, OrganizationReapprovalAction.ReapprovalWithEmail); + resultLogo = await UpdateLogo(result, file, OrganizationReapprovalAction.ReapprovalWithNotification); result.ModifiedByUserId = user.Id; result = await _organizationRepository.Update(result); scope.Complete(); @@ -793,7 +790,7 @@ public async Task AddDocuments(Guid id, OrganizationDocumentType t ValidateUpdatable(result); - var user = _userService.GetByEmail(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, !ensureOrganizationAuthorization), false, false); + var user = _userService.GetByUsername(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, !ensureOrganizationAuthorization), false, false); var statusCurrent = result.Status; @@ -801,7 +798,7 @@ public async Task AddDocuments(Guid id, OrganizationDocumentType t await _executionStrategyService.ExecuteInExecutionStrategyAsync(async () => { using var scope = new TransactionScope(TransactionScopeOption.RequiresNew, TransactionScopeAsyncFlowOption.Enabled); - resultDocuments = await AddDocuments(result, type, documents, OrganizationReapprovalAction.ReapprovalWithEmail); + resultDocuments = await AddDocuments(result, type, documents, OrganizationReapprovalAction.ReapprovalWithNotification); result.ModifiedByUserId = user.Id; result = await _organizationRepository.Update(result); scope.Complete(); @@ -833,7 +830,7 @@ public async Task DeleteDocuments(Guid id, OrganizationDocumentTyp if (isProviderTypeMarketplace && (result.Documents == null || result.Documents.Where(o => o.Type == OrganizationDocumentType.Business).All(o => documentFileIds.Contains(o.FileId)))) throw new ValidationException($"Business documents are required. Removal will result in no associated documents"); - var user = _userService.GetByEmail(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, !ensureOrganizationAuthorization), false, false); + var user = _userService.GetByUsername(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, !ensureOrganizationAuthorization), false, false); var statusCurrent = result.Status; @@ -841,7 +838,7 @@ public async Task DeleteDocuments(Guid id, OrganizationDocumentTyp await _executionStrategyService.ExecuteInExecutionStrategyAsync(async () => { using var scope = new TransactionScope(TransactionScopeOption.RequiresNew, TransactionScopeAsyncFlowOption.Enabled); - resultDelete = await DeleteDocuments(result, type, documentFileIds, OrganizationReapprovalAction.ReapprovalWithEmail); + resultDelete = await DeleteDocuments(result, type, documentFileIds, OrganizationReapprovalAction.ReapprovalWithNotification); result.ModifiedByUserId = user.Id; result = await _organizationRepository.Update(result); scope.Complete(); @@ -856,20 +853,20 @@ await _executionStrategyService.ExecuteInExecutionStrategyAsync(async () => return resultDelete.Organization; } - public async Task AssignAdmins(Guid id, List emails, bool ensureOrganizationAuthorization) + public async Task AssignAdmins(Guid id, List usernames, bool ensureOrganizationAuthorization) { var result = GetById(id, true, true, ensureOrganizationAuthorization); ValidateUpdatable(result); - var user = _userService.GetByEmail(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, !ensureOrganizationAuthorization), false, false); + var user = _userService.GetByUsername(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, !ensureOrganizationAuthorization), false, false); var statusCurrent = result.Status; await _executionStrategyService.ExecuteInExecutionStrategyAsync(async () => { using var scope = new TransactionScope(TransactionScopeOption.RequiresNew, TransactionScopeAsyncFlowOption.Enabled); - result = await AssignAdmins(result, emails, OrganizationReapprovalAction.ReapprovalWithEmail); + result = await AssignAdmins(result, usernames, OrganizationReapprovalAction.ReapprovalWithNotification); result.ModifiedByUserId = user.Id; result = await _organizationRepository.Update(result); scope.Complete(); @@ -881,23 +878,23 @@ await _executionStrategyService.ExecuteInExecutionStrategyAsync(async () => return result; } - public async Task RemoveAdmins(Guid id, List emails, bool ensureOrganizationAuthorization) + public async Task RemoveAdmins(Guid id, List usernames, bool ensureOrganizationAuthorization) { var result = GetById(id, true, true, ensureOrganizationAuthorization); - if (emails == null || emails.Count == 0) - throw new ArgumentNullException(nameof(emails)); + if (usernames == null || usernames.Count == 0) + throw new ArgumentNullException(nameof(usernames)); ValidateUpdatable(result); - var user = _userService.GetByEmail(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, !ensureOrganizationAuthorization), false, false); + var user = _userService.GetByUsername(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, !ensureOrganizationAuthorization), false, false); var statusCurrent = result.Status; await _executionStrategyService.ExecuteInExecutionStrategyAsync(async () => { using var scope = new TransactionScope(TransactionScopeOption.RequiresNew, TransactionScopeAsyncFlowOption.Enabled); - result = await RemoveAdmins(result, emails, OrganizationReapprovalAction.ReapprovalWithEmail); + result = await RemoveAdmins(result, usernames, OrganizationReapprovalAction.ReapprovalWithNotification); result.ModifiedByUserId = user.Id; result = await _organizationRepository.Update(result); scope.Complete(); @@ -937,7 +934,7 @@ public async Task AllocateRewards(Organization organization, decimal? zltoReward organization.ZltoRewardBalance = organization.ZltoRewardPool.HasValue ? organization.ZltoRewardPool - (organization.ZltoRewardCumulative ?? default) : null; organization.YomaRewardBalance = organization.YomaRewardPool.HasValue ? organization.YomaRewardPool - (organization.YomaRewardCumulative ?? default) : null; - var user = _userService.GetByEmail(HttpContextAccessorHelper.GetUsernameSystem, false, false); + var user = _userService.GetByUsername(HttpContextAccessorHelper.GetUsernameSystem, false, false); organization.ModifiedByUserId = user.Id; await _organizationRepository.Update(organization); @@ -953,7 +950,7 @@ public bool IsAdminsOf(List ids, bool throwUnauthorized) { if (ids.Count == 0) throw new ArgumentNullException(nameof(ids)); - var user = _userService.GetByEmail(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, false), false, false); + var user = _userService.GetByUsername(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, false), false, false); var orgIds = _organizationUserRepository.Query().Where(o => o.UserId == user.Id).Select(o => o.OrganizationId).ToList(); var result = !ids.Except(orgIds).Any(); @@ -971,7 +968,7 @@ public List ListAdmins(Guid id, bool includeComputed, bool ensureOrgan public List ListAdminsOf(bool includeComputed) { - var user = _userService.GetByEmail(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, false), false, false); + var user = _userService.GetByUsername(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, false), false, false); var orgIds = _organizationUserRepository.Query().Where(o => o.UserId == user.Id).Select(o => o.OrganizationId).ToList(); var organizations = _organizationRepository.Query().Where(o => orgIds.Contains(o.Id)).ToList(); @@ -983,7 +980,7 @@ public List ListAdminsOf(bool includeComputed) #region Private Members private bool IsAdmin(Organization organization, bool throwUnauthorized) { - var user = _userService.GetByEmail(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, false), false, false); + var user = _userService.GetByUsername(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, false), false, false); var isAdmin = HttpContextAccessorHelper.IsAdminRole(_httpContextAccessor); @@ -1123,18 +1120,18 @@ await _executionStrategyService.ExecuteInExecutionStrategyAsync(async () => return (organization, blobObject); } - private async Task AssignAdmins(Organization organization, List emails, OrganizationReapprovalAction reapprovalAction) + private async Task AssignAdmins(Organization organization, List usernames, OrganizationReapprovalAction reapprovalAction) { - if (emails == null || emails.Count == 0) - throw new ArgumentNullException(nameof(emails)); + if (usernames == null || usernames.Count == 0) + throw new ArgumentNullException(nameof(usernames)); await _executionStrategyService.ExecuteInExecutionStrategyAsync(async () => { using var scope = new TransactionScope(TransactionScopeOption.Required, TransactionScopeAsyncFlowOption.Enabled); var updated = false; - foreach (var email in emails) + foreach (var username in usernames) { - var user = _userService.GetByEmail(email, false, false); + var user = _userService.GetByUsername(username, false, false); if (!user.ExternalId.HasValue) throw new InvalidOperationException($"External id expected for user with id '{user.Id}'"); @@ -1168,19 +1165,19 @@ await _executionStrategyService.ExecuteInExecutionStrategyAsync(async () => return organization; } - private async Task RemoveAdmins(Organization organization, List? emails, OrganizationReapprovalAction reapprovalAction) + private async Task RemoveAdmins(Organization organization, List? usernames, OrganizationReapprovalAction reapprovalAction) { - if (emails == null || emails.Count == 0) return organization; + if (usernames == null || usernames.Count == 0) return organization; - emails = emails.Distinct().ToList(); + usernames = usernames.Distinct().ToList(); await _executionStrategyService.ExecuteInExecutionStrategyAsync(async () => { using var scope = new TransactionScope(TransactionScopeOption.Required, TransactionScopeAsyncFlowOption.Enabled); var updated = false; - foreach (var email in emails) + foreach (var username in usernames) { - var user = _userService.GetByEmail(email, false, false); + var user = _userService.GetByUsername(username, false, false); if (!user.ExternalId.HasValue) throw new InvalidOperationException($"External id expected for user with id '{user.Id}'"); @@ -1217,7 +1214,7 @@ private async Task SendForReapproval(Organization organization, Or return organization; case OrganizationReapprovalAction.Reapproval: - case OrganizationReapprovalAction.ReapprovalWithEmail: + case OrganizationReapprovalAction.ReapprovalWithNotification: if (requiredStatus != null && organization.Status != requiredStatus) return organization; if (organization.Status == OrganizationStatus.Inactive) return organization; @@ -1227,8 +1224,8 @@ private async Task SendForReapproval(Organization organization, Or organization.Status = OrganizationStatus.Inactive; organization = await _organizationRepository.Update(organization); - if (action == OrganizationReapprovalAction.ReapprovalWithEmail) - await SendEmail(organization, EmailType.Organization_Approval_Requested); + if (action == OrganizationReapprovalAction.ReapprovalWithNotification) + await SendNotification(organization, NotificationType.Organization_Approval_Requested); break; @@ -1361,52 +1358,49 @@ private static void ValidateUpdatable(Organization organization) throw new ValidationException($"{nameof(Organization)} '{organization.Name}' can no longer be updated (current status '{organization.Status}'). Required state '{string.Join(" / ", Statuses_Updatable)}'"); } - private async Task SendEmail(Organization organization, EmailType type) + private async Task SendNotification(Organization organization, NotificationType type) { try { - List? recipients = null; + List? recipients = null; - var dataOrg = new EmailOrganizationApprovalItem { Name = organization.Name }; + var dataOrg = new NotificationOrganizationApprovalItem { Name = organization.Name }; switch (type) { - case EmailType.Organization_Approval_Requested: - //send email to super administrators + case NotificationType.Organization_Approval_Requested: + //send notification to super administrators var superAdmins = await _identityProviderClient.ListByRole(Constants.Role_Admin); - recipients = superAdmins?.Select(o => new EmailRecipient { Email = o.Email, DisplayName = o.ToDisplayName() }).ToList(); + recipients = superAdmins?.Select(o => new NotificationRecipient { Username = o.Username, PhoneNumber = o.PhoneNumber, Email = o.Email, DisplayName = o.ToDisplayName() ?? o.Username }).ToList(); dataOrg.Comment = organization.CommentApproval; - dataOrg.URL = _emailURLFactory.OrganizationApprovalItemURL(type, organization.Id); + dataOrg.URL = _notificationURLFactory.OrganizationApprovalItemURL(type, organization.Id); break; - case EmailType.Organization_Approval_Approved: - case EmailType.Organization_Approval_Declined: - //send email to organization administrators - recipients = organization.Administrators?.Select(o => new EmailRecipient { Email = o.Email, DisplayName = o.DisplayName }).ToList(); + case NotificationType.Organization_Approval_Approved: + case NotificationType.Organization_Approval_Declined: + //send notification to organization administrators + recipients = organization.Administrators?.Select(o => new NotificationRecipient { Username = o.Username, PhoneNumber = o.PhoneNumber, Email = o.Email, DisplayName = o.DisplayName }).ToList(); dataOrg.Comment = organization.CommentApproval; - dataOrg.URL = _emailURLFactory.OrganizationApprovalItemURL(type, organization.Id); + dataOrg.URL = _notificationURLFactory.OrganizationApprovalItemURL(type, organization.Id); break; default: throw new ArgumentOutOfRangeException(nameof(type), $"Type of '{type}' not supported"); } - recipients = _emailPreferenceFilterService.FilterRecipients(type, recipients); - if (recipients == null || recipients.Count == 0) return; - - var data = new EmailOrganizationApproval + var data = new NotificationOrganizationApproval { Organizations = [dataOrg] }; - await _emailProviderClient.Send(type, recipients, data); + await _notificationDeliveryService.Send(type, recipients, data); - _logger.LogInformation("Successfully send email"); + _logger.LogInformation("Successfully send notification"); } catch (Exception ex) { - _logger.LogError(ex, "Failed to send email"); + _logger.LogError(ex, "Failed to send notification"); } } #endregion diff --git a/src/api/src/domain/Yoma.Core.Domain/Entity/Services/UserBackgroundService.cs b/src/api/src/domain/Yoma.Core.Domain/Entity/Services/UserBackgroundService.cs index 711642375..2ca47bd0b 100644 --- a/src/api/src/domain/Yoma.Core.Domain/Entity/Services/UserBackgroundService.cs +++ b/src/api/src/domain/Yoma.Core.Domain/Entity/Services/UserBackgroundService.cs @@ -111,7 +111,7 @@ private async Task SeedPhotos(List items) var fileExtension = Path.GetExtension(fileName)[1..]; foreach (var item in items) - await _userService.UpsertPhoto(item.Email, FileHelper.FromByteArray(fileName, $"image/{fileExtension}", resourceBytes)); + await _userService.UpsertPhoto(item.Username, FileHelper.FromByteArray(fileName, $"image/{fileExtension}", resourceBytes)); } #endregion } diff --git a/src/api/src/domain/Yoma.Core.Domain/Entity/Services/UserProfileService.cs b/src/api/src/domain/Yoma.Core.Domain/Entity/Services/UserProfileService.cs index 8176de245..cf48e0c20 100644 --- a/src/api/src/domain/Yoma.Core.Domain/Entity/Services/UserProfileService.cs +++ b/src/api/src/domain/Yoma.Core.Domain/Entity/Services/UserProfileService.cs @@ -29,7 +29,7 @@ public class UserProfileService : IUserProfileService private readonly IEducationService _educationService; private readonly IOrganizationService _organizationService; private readonly IMyOpportunityService _myOpportunityService; - private readonly IWalletService _rewardWalletService; + private readonly IWalletService _walletService; private readonly UserProfileRequestValidator _userProfileRequestValidator; private readonly IRepositoryValueContainsWithNavigation _userRepository; private readonly IExecutionStrategyService _executionStrategyService; @@ -44,7 +44,7 @@ public UserProfileService(IHttpContextAccessor httpContextAccessor, IEducationService educationService, IOrganizationService organizationService, IMyOpportunityService myOpportunityService, - IWalletService rewardWalletService, + IWalletService walletService, UserProfileRequestValidator userProfileRequestValidator, IRepositoryValueContainsWithNavigation userRepository, IExecutionStrategyService executionStrategyService) @@ -57,7 +57,7 @@ public UserProfileService(IHttpContextAccessor httpContextAccessor, _educationService = educationService; _organizationService = organizationService; _myOpportunityService = myOpportunityService; - _rewardWalletService = rewardWalletService; + _walletService = walletService; _userProfileRequestValidator = userProfileRequestValidator; _userRepository = userRepository; _executionStrategyService = executionStrategyService; @@ -68,21 +68,21 @@ public UserProfileService(IHttpContextAccessor httpContextAccessor, public UserProfile Get() { var username = HttpContextAccessorHelper.GetUsername(_httpContextAccessor, false); - var user = _userService.GetByEmail(username, true, true); + var user = _userService.GetByUsername(username, true, true); return ToProfile(user).Result; } public List? GetSkills() { var username = HttpContextAccessorHelper.GetUsername(_httpContextAccessor, false); - var user = _userService.GetByEmail(username, true, true); + var user = _userService.GetByUsername(username, true, true); return user.Skills; } public Settings GetSettings() { var username = HttpContextAccessorHelper.GetUsername(_httpContextAccessor, false); - return SettingsHelper.FilterByRoles(_userService.GetSettingsByEmail(username), HttpContextAccessorHelper.GetRoles(_httpContextAccessor)); + return SettingsHelper.FilterByRoles(_userService.GetSettingsByUsername(username), HttpContextAccessorHelper.GetRoles(_httpContextAccessor)); } public async Task UpsertPhoto(IFormFile file) @@ -114,57 +114,65 @@ public async Task Update(UserRequestProfile request) var username = HttpContextAccessorHelper.GetUsername(_httpContextAccessor, false); - var user = _userService.GetByEmail(username, true, true); + var user = _userService.GetByUsername(username, true, true); if (!user.ExternalId.HasValue) throw new InvalidOperationException($"External id expected for user with id '{user.Id}'"); var externalId = user.ExternalId.Value; - var emailUpdated = !string.Equals(user.Email, request.Email, StringComparison.InvariantCultureIgnoreCase); + if (!string.IsNullOrEmpty(user.Email) && string.IsNullOrEmpty(request.Email)) + throw new ValidationException("Email is required"); + + var emailUpdated = !(string.Equals(user.Email ?? string.Empty, request.Email ?? string.Empty, StringComparison.InvariantCultureIgnoreCase)); if (emailUpdated) - //email address updates: pending ZLTO integration and ability to update wallet email address - throw new ValidationException("Email address updates are currently restricted. Please contact support for assistance"); - //if (_userService.GetByEmailOrNull(request.Email, false, false) != null) - // throw new ValidationException($"{nameof(User)} with the specified email address '{request.Email}' already exists"); + { + if (_userService.GetByEmailOrNull(request.Email, false, false) != null) + throw new ValidationException($"{nameof(User)} with the specified email address '{request.Email}' already exists"); + } - user.Email = request.Email.ToLower(); + user.Email = request.Email?.ToLower(); if (emailUpdated) user.EmailConfirmed = false; user.FirstName = request.FirstName.TitleCase(); user.Surname = request.Surname.TitleCase(); - user.DisplayName = request.DisplayName ?? string.Empty; + user.DisplayName = request.DisplayName; user.SetDisplayName(); - user.PhoneNumber = request.PhoneNumber; user.CountryId = request.CountryId; user.EducationId = request.EducationId; user.GenderId = request.GenderId; user.DateOfBirth = request.DateOfBirth; + if (string.IsNullOrEmpty(user.Email) && string.IsNullOrEmpty(user.PhoneNumber)) + throw new InvalidOperationException("Email or phone number is required"); + await _executionStrategyService.ExecuteInExecutionStrategyAsync(async () => { using var scope = new TransactionScope(TransactionScopeOption.RequiresNew, TransactionScopeAsyncFlowOption.Enabled); user = await _userRepository.Update(user); + username = user.Email ?? user.PhoneNumber; + if (string.IsNullOrEmpty(username)) + throw new InvalidOperationException("Username is required"); + var userIdentityProvider = new IdentityProvider.Models.User { Id = externalId, FirstName = user.FirstName, LastName = user.Surname, - Username = user.Email, + Username = username, Email = user.Email, - EmailVerified = user.EmailConfirmed, - PhoneNumber = user.PhoneNumber, + EmailVerified = user.EmailConfirmed ?? false, Gender = user.GenderId.HasValue ? _genderService.GetById(user.GenderId.Value).Name : null, Country = user.CountryId.HasValue ? _countryService.GetById(user.CountryId.Value).Name : null, Education = user.EducationId.HasValue ? _educationService.GetById(user.EducationId.Value).Name : null, DateOfBirth = user.DateOfBirth.HasValue ? user.DateOfBirth.Value.ToString("yyyy/MM/dd") : null }; - await _identityProviderClient.UpdateUser(userIdentityProvider, request.ResetPassword, emailUpdated); + await _identityProviderClient.UpdateUser(userIdentityProvider, request.ResetPassword, emailUpdated, request.UpdatePhoneNumber); scope.Complete(); }); - HttpContextAccessorHelper.UpdateUsername(_httpContextAccessor, user.Email); + HttpContextAccessorHelper.UpdateUsername(_httpContextAccessor, username); return await ToProfile(user); } @@ -177,7 +185,7 @@ private async Task ToProfile(User user) result.Settings = SettingsHelper.FilterByRoles(result.Settings, HttpContextAccessorHelper.GetRoles(_httpContextAccessor)); - var (status, balance) = await _rewardWalletService.GetWalletStatusAndBalance(result.Id); + var (status, balance) = await _walletService.GetWalletStatusAndBalance(result.Id); result.Zlto = new UserProfileZlto { Pending = balance.Pending, diff --git a/src/api/src/domain/Yoma.Core.Domain/Entity/Services/UserService.cs b/src/api/src/domain/Yoma.Core.Domain/Entity/Services/UserService.cs index 7adb37553..b8869fdfe 100644 --- a/src/api/src/domain/Yoma.Core.Domain/Entity/Services/UserService.cs +++ b/src/api/src/domain/Yoma.Core.Domain/Entity/Services/UserService.cs @@ -84,26 +84,37 @@ public UserService( #endregion #region Public Members - public User GetByEmail(string? email, bool includeChildItems, bool includeComputed) + public User GetByUsername(string username, bool includeChildItems, bool includeComputed) { - if (string.IsNullOrWhiteSpace(email)) - throw new ArgumentNullException(nameof(email)); - - var result = GetByEmailOrNull(email, includeChildItems, includeComputed) - ?? throw new EntityNotFoundException($"User with email '{email}' does not exist"); + var result = GetByUsernameOrNull(username, includeChildItems, includeComputed) + ?? throw new EntityNotFoundException($"User with username '{username}' does not exist"); return result; } - public User? GetByEmailOrNull(string email, bool includeChildItems, bool includeComputed) + public User? GetByUsernameOrNull(string? username, bool includeChildItems, bool includeComputed) + { + username = username?.Trim(); + if (string.IsNullOrEmpty(username)) return null; + + if (username.Contains('@')) + return GetByEmailOrNull(username, includeChildItems, includeComputed); + else + return GetByPhoneOrNull(username, includeChildItems, includeComputed); + } + + public User? GetByEmailOrNull(string? email, bool includeChildItems, bool includeComputed) { - if (string.IsNullOrWhiteSpace(email)) - throw new ArgumentNullException(nameof(email)); - email = email.Trim(); + email = email?.Trim(); + if (string.IsNullOrEmpty(email)) return null; + var query = _userRepository.Query(includeChildItems) #pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons - var result = _userRepository.Query(includeChildItems).SingleOrDefault(o => o.Email.ToLower() == email.ToLower()); + .Where(o => !string.IsNullOrEmpty(o.Email) && o.Email.ToLower() == email.ToLower()); #pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons + + var result = query.SingleOrDefault(); + if (result == null) return null; if (includeComputed) @@ -116,11 +127,47 @@ public User GetByEmail(string? email, bool includeChildItems, bool includeComput return result; } - public User GetById(Guid id, bool includeChildItems, bool includeComputed) + public User? GetByPhoneOrNull(string? phoneNumber, bool includeChildItems, bool includeComputed) { - if (id == Guid.Empty) - throw new ArgumentNullException(nameof(id)); + phoneNumber = phoneNumber?.Trim(); + if (string.IsNullOrEmpty(phoneNumber)) return null; + var query = _userRepository.Query(includeChildItems) + .Where(o => !string.IsNullOrEmpty(o.PhoneNumber) && o.PhoneNumber == phoneNumber); + + var result = query.SingleOrDefault(); + if (result == null) return null; + + if (includeComputed) + { + result.PhotoURL = GetBlobObjectURL(result.PhotoStorageType, result.PhotoKey); + result.Skills?.ForEach(o => o.Organizations?.ForEach(o => o.LogoURL = GetBlobObjectURL(o.LogoStorageType, o.LogoKey))); + result.Settings = SettingsHelper.ParseInfo(_settingsDefinitionService.ListByEntityType(EntityType.User), result.SettingsRaw); + } + + return result; + } + + public User? GetByExternalIdOrNull(Guid externalId, bool includeChildItems, bool includeComputed) + { + if (externalId == Guid.Empty) + throw new ArgumentNullException(nameof(externalId)); + + var result = _userRepository.Query(includeChildItems).SingleOrDefault(o => o.ExternalId == externalId); + if (result == null) return null; + + if (includeComputed) + { + result.PhotoURL = GetBlobObjectURL(result.PhotoStorageType, result.PhotoKey); + result.Skills?.ForEach(o => o.Organizations?.ForEach(o => o.LogoURL = GetBlobObjectURL(o.LogoStorageType, o.LogoKey))); + result.Settings = SettingsHelper.ParseInfo(_settingsDefinitionService.ListByEntityType(EntityType.User), result.SettingsRaw); + } + + return result; + } + + public User GetById(Guid id, bool includeChildItems, bool includeComputed) + { var result = GetByIdOrNull(id, includeChildItems, includeComputed) ?? throw new EntityNotFoundException($"{nameof(User)} with id '{id}' does not exist"); @@ -145,15 +192,15 @@ public User GetById(Guid id, bool includeChildItems, bool includeComputed) return result; } - public Settings GetSettingsByEmail(string email) + public Settings GetSettingsByUsername(string username) { - var user = GetByEmail(email, false, false); + var user = GetByUsername(username, false, false); return SettingsHelper.Parse(_settingsDefinitionService.ListByEntityType(EntityType.User), user.SettingsRaw); } - public SettingsInfo GetSettingsInfoByEmail(string email) + public SettingsInfo GetSettingsInfoByUsername(string username) { - var user = GetByEmail(email, false, false); + var user = GetByUsername(username, false, false); return SettingsHelper.ParseInfo(_settingsDefinitionService.ListByEntityType(EntityType.User), user.SettingsRaw); } @@ -194,8 +241,8 @@ public UserSearchResults Search(UserSearchFilter filter) var query = _userRepository.Query(); - //only includes users which email has been confirmed (implies linked to identity provider) - query = query.Where(o => o.EmailConfirmed); + //only includes users with associated external id's (implies linked to identity provider) + query = query.Where(o => o.ExternalId.HasValue); //yoIDOnboarded if (filter.YoIDOnboarded == true) @@ -224,6 +271,10 @@ public async Task Upsert(UserRequest request) await _userRequestValidator.ValidateAndThrowAsync(request); + var usernameExpected = request.Email ?? request.PhoneNumber; + if (!string.Equals(request.Username, usernameExpected)) + throw new InvalidOperationException($"Username '{request.Username}' does not match expected value '{usernameExpected}'"); + // check if user exists var isNew = !request.Id.HasValue; var result = !request.Id.HasValue ? new User { Id = Guid.NewGuid() } : GetById(request.Id.Value, false, false); @@ -232,18 +283,19 @@ public async Task Upsert(UserRequest request) if (existingByEmail != null && (isNew || result.Id != existingByEmail.Id)) throw new ValidationException($"{nameof(User)} with the specified email address '{request.Email}' already exists"); + var existingByPhone = GetByPhoneOrNull(request.PhoneNumber, false, false); + if (existingByPhone != null && (isNew || result.Id != existingByPhone.Id)) + throw new ValidationException($"{nameof(User)} with the specified phone number '{request.PhoneNumber}' already exists"); + // profile fields updatable via UserProfileService.Update; identity provider is source of truth if (isNew) { - var kcUser = await _identityProviderClient.GetUser(request.Email) - ?? throw new InvalidOperationException($"{nameof(User)} with email '{request.Email}' does not exist"); - //preserve keycloak formatting for email, first name and surname - result.Email = request.Email; + var kcUser = await _identityProviderClient.GetUser(request.Username) + ?? throw new InvalidOperationException($"{nameof(User)} with username '{request.Username}' does not exist"); result.FirstName = request.FirstName; result.Surname = request.Surname; - result.DisplayName = request.DisplayName ?? string.Empty; + result.DisplayName = request.DisplayName; result.SetDisplayName(); - result.PhoneNumber = request.PhoneNumber; result.CountryId = request.CountryId; result.EducationId = request.EducationId; result.GenderId = request.GenderId; @@ -251,7 +303,11 @@ public async Task Upsert(UserRequest request) result.Settings = SettingsHelper.ParseInfo(_settingsDefinitionService.ListByEntityType(EntityType.User), (string?)null); } + result.Username = request.Username; + result.Email = request.Email; result.EmailConfirmed = request.EmailConfirmed; + result.PhoneNumber = request.PhoneNumber; + result.PhoneNumberConfirmed = request.PhoneNumberConfirmed; result.DateLastLogin = request.DateLastLogin; result.ExternalId = request.ExternalId; @@ -260,9 +316,9 @@ public async Task Upsert(UserRequest request) return result; } - public async Task UpsertPhoto(string? email, IFormFile? file) + public async Task UpsertPhoto(string username, IFormFile? file) { - var result = GetByEmail(email, true, true); + var result = GetByUsername(username, true, true); ArgumentNullException.ThrowIfNull(file, nameof(file)); @@ -300,13 +356,13 @@ await _executionStrategyService.ExecuteInExecutionStrategyAsync(async () => return result; } - public async Task UpdateSettings(string? email, List? roles, SettingsRequest request) + public async Task UpdateSettings(string username, List? roles, SettingsRequest request) { ArgumentNullException.ThrowIfNull(request, nameof(request)); await _settingsRequestValidator.ValidateAndThrowAsync(request); - var result = GetByEmail(email, false, false); + var result = GetByUsername(username, false, false); var definitions = _settingsDefinitionService.ListByEntityType(EntityType.User); @@ -363,12 +419,12 @@ await _executionStrategyService.ExecuteInExecutionStrategyAsync(async () => }); } - public async Task YoIDOnboard(string? email) + public async Task YoIDOnboard(string username) { - var result = GetByEmail(email, true, true); + var result = GetByUsername(username, true, true); if (result.YoIDOnboarded.HasValue && result.YoIDOnboarded.Value) - throw new ValidationException($"User with email '{email}' has already completed YoID onboarding"); + throw new ValidationException($"User with username '{username}' has already completed YoID onboarding"); await _executionStrategyService.ExecuteInExecutionStrategyAsync(async () => { diff --git a/src/api/src/domain/Yoma.Core.Domain/Entity/Validators/OrganizationRequestValidatorBase.cs b/src/api/src/domain/Yoma.Core.Domain/Entity/Validators/OrganizationRequestValidatorBase.cs index 375427c39..966f2c006 100644 --- a/src/api/src/domain/Yoma.Core.Domain/Entity/Validators/OrganizationRequestValidatorBase.cs +++ b/src/api/src/domain/Yoma.Core.Domain/Entity/Validators/OrganizationRequestValidatorBase.cs @@ -57,11 +57,16 @@ public OrganizationRequestValidatorBase(ICountryService countryService, IOrganiz .WithMessage("Business documents are optional, but if specified, cannot be empty.")) .When(x => x.BusinessDocuments != null && x.BusinessDocuments.Count > 0); - RuleFor(x => x.AdminEmails).Must(emails => emails != null && emails.Count != 0).When(x => !x.AddCurrentUserAsAdmin) - .WithMessage("Additional administrative emails are required provided not adding the current user as an admin."); - RuleFor(x => x.AdminEmails).Must(emails => emails != null && emails.All(email => !string.IsNullOrWhiteSpace(email) && new EmailAddressAttribute().IsValid(email))) - .WithMessage("Additional administrative emails contain invalid addresses.") - .When(x => x.AdminEmails != null && x.AdminEmails.Count != 0); + RuleFor(x => x.Admins).Must(items => items != null && items.Count != 0).When(x => !x.AddCurrentUserAsAdmin) + .WithMessage("Additional administrators are required if not adding the current user as an admin."); + + RuleFor(x => x.Admins) + .Must(items => items != null && items.All(admin => + (!string.IsNullOrWhiteSpace(admin) && + (new EmailAddressAttribute().IsValid(admin) || RegExValidators.PhoneNumber().IsMatch(admin)) + ))) + .WithMessage("Additional administrative username(s) must contain either a valid email address or phone number.") + .When(x => x.Admins != null && x.Admins.Count != 0); RuleFor(x => x.ZltoRewardPool) .GreaterThan(0).When(x => x.ZltoRewardPool.HasValue).WithMessage("'{PropertyName}' must be greater than 0.") diff --git a/src/api/src/domain/Yoma.Core.Domain/Entity/Validators/UserProfileRequestValidator.cs b/src/api/src/domain/Yoma.Core.Domain/Entity/Validators/UserProfileRequestValidator.cs index da6e1525e..34258c2c7 100644 --- a/src/api/src/domain/Yoma.Core.Domain/Entity/Validators/UserProfileRequestValidator.cs +++ b/src/api/src/domain/Yoma.Core.Domain/Entity/Validators/UserProfileRequestValidator.cs @@ -1,3 +1,4 @@ +using FluentValidation; using Yoma.Core.Domain.Entity.Models; using Yoma.Core.Domain.Lookups.Interfaces; @@ -10,7 +11,11 @@ public class UserProfileRequestValidator : UserRequestValidatorBase x.FirstName).NotEmpty().Length(1, 125); + RuleFor(x => x.Surname).NotEmpty().Length(1, 125); + } #endregion } } diff --git a/src/api/src/domain/Yoma.Core.Domain/Entity/Validators/UserRequestValidator.cs b/src/api/src/domain/Yoma.Core.Domain/Entity/Validators/UserRequestValidator.cs index e79d79394..e3a62b24d 100644 --- a/src/api/src/domain/Yoma.Core.Domain/Entity/Validators/UserRequestValidator.cs +++ b/src/api/src/domain/Yoma.Core.Domain/Entity/Validators/UserRequestValidator.cs @@ -1,4 +1,5 @@ using FluentValidation; +using Yoma.Core.Domain.Core.Validators; using Yoma.Core.Domain.Entity.Models; using Yoma.Core.Domain.Lookups.Interfaces; @@ -14,6 +15,9 @@ public UserRequestValidator(ICountryService countryService, IEducationService ed IGenderService genderService) : base(countryService, educationService, genderService) { RuleFor(x => x.Id).NotEmpty().When(x => x.Id.HasValue); + RuleFor(x => x.FirstName).Length(1, 125).When(x => !string.IsNullOrEmpty(x.FirstName)); + RuleFor(x => x.Surname).Length(1, 125).When(x => !string.IsNullOrEmpty(x.Surname)); + RuleFor(x => x.PhoneNumber).Length(1, 50).Matches(RegExValidators.PhoneNumber()).WithMessage("'{PropertyName}' is invalid.").When(x => !string.IsNullOrEmpty(x.PhoneNumber)); RuleFor(x => x.DateLastLogin).Must(NotInFuture).WithMessage("'{PropertyName}' is in the future."); } #endregion diff --git a/src/api/src/domain/Yoma.Core.Domain/Entity/Validators/UserRequestValidatorBase.cs b/src/api/src/domain/Yoma.Core.Domain/Entity/Validators/UserRequestValidatorBase.cs index 849ee0519..8b0ff04a2 100644 --- a/src/api/src/domain/Yoma.Core.Domain/Entity/Validators/UserRequestValidatorBase.cs +++ b/src/api/src/domain/Yoma.Core.Domain/Entity/Validators/UserRequestValidatorBase.cs @@ -1,7 +1,6 @@ using FluentValidation; using Yoma.Core.Domain.Core; using Yoma.Core.Domain.Core.Extensions; -using Yoma.Core.Domain.Core.Validators; using Yoma.Core.Domain.Entity.Models; using Yoma.Core.Domain.Lookups.Interfaces; @@ -25,10 +24,8 @@ public UserRequestValidatorBase(ICountryService countryService, _educationService = educationService; _genderService = genderService; - RuleFor(x => x.Email).NotEmpty().EmailAddress(); - RuleFor(x => x.FirstName).NotEmpty().Length(1, 320); - RuleFor(x => x.Surname).NotEmpty().Length(1, 320); - RuleFor(x => x.PhoneNumber).Length(1, 50).Matches(RegExValidators.PhoneNumber()).WithMessage("'{PropertyName}' is invalid.").When(x => !string.IsNullOrEmpty(x.PhoneNumber)); + RuleFor(x => x.Email).EmailAddress().When(x => !string.IsNullOrEmpty(x.Email)); + RuleFor(x => x.DisplayName).Length(1, 255).When(x => !string.IsNullOrEmpty(x.DisplayName)); RuleFor(x => x.CountryId).Must(CountryExists).WithMessage($"Specified country is invalid / does not exist. 'Worldwide' is not allowed as a country selection."); RuleFor(x => x.EducationId).Must(EducationExists).WithMessage($"Specified education is invalid / does not exist."); RuleFor(x => x.GenderId).Must(GenderExists).WithMessage($"Specified gender is invalid / does not exist."); diff --git a/src/api/src/domain/Yoma.Core.Domain/IdentityProvider/Interfaces/IIdentityProviderClient.cs b/src/api/src/domain/Yoma.Core.Domain/IdentityProvider/Interfaces/IIdentityProviderClient.cs index 42f27623c..d6dcf6368 100644 --- a/src/api/src/domain/Yoma.Core.Domain/IdentityProvider/Interfaces/IIdentityProviderClient.cs +++ b/src/api/src/domain/Yoma.Core.Domain/IdentityProvider/Interfaces/IIdentityProviderClient.cs @@ -9,7 +9,7 @@ public interface IIdentityProviderClient Task GetUser(string? username); - Task UpdateUser(User user, bool resetPassword, bool sendVerifyEmail); + Task UpdateUser(User user, bool resetPassword, bool sendVerifyEmail, bool updatePhoneNumber); Task EnsureRoles(Guid id, List roles); diff --git a/src/api/src/domain/Yoma.Core.Domain/IdentityProvider/Models/User.cs b/src/api/src/domain/Yoma.Core.Domain/IdentityProvider/Models/User.cs index 5b43a0d99..dd99b60ba 100644 --- a/src/api/src/domain/Yoma.Core.Domain/IdentityProvider/Models/User.cs +++ b/src/api/src/domain/Yoma.Core.Domain/IdentityProvider/Models/User.cs @@ -6,11 +6,11 @@ public class User public string Username { get; set; } - public string Email { get; set; } + public string? Email { get; set; } - public string FirstName { get; set; } + public string? FirstName { get; set; } - public string LastName { get; set; } + public string? LastName { get; set; } public string? PhoneNumber { get; set; } @@ -22,6 +22,8 @@ public class User public string? DateOfBirth { get; set; } - public bool EmailVerified { get; set; } + public bool? EmailVerified { get; set; } + + public bool? PhoneNumberVerified { get; set; } } } diff --git a/src/api/src/domain/Yoma.Core.Domain/Marketplace/Services/MarketPlaceService.cs b/src/api/src/domain/Yoma.Core.Domain/Marketplace/Services/MarketPlaceService.cs index 057e46dcb..e4fbf5680 100644 --- a/src/api/src/domain/Yoma.Core.Domain/Marketplace/Services/MarketPlaceService.cs +++ b/src/api/src/domain/Yoma.Core.Domain/Marketplace/Services/MarketPlaceService.cs @@ -116,7 +116,7 @@ public async Task SearchStoreItemCategories(Stor List? myOpportunitiesCompleted = null; if (HttpContextAccessorHelper.UserContextAvailable(_httpContextAccessor)) { - user = _userService.GetByEmail(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, false), false, false); + user = _userService.GetByUsername(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, false), false, false); var myOpportunitySearchFilter = new MyOpportunitySearchFilter { @@ -179,7 +179,7 @@ public async Task BuyItem(string storeId, string itemCategoryId) throw new ArgumentNullException(nameof(itemCategoryId)); itemCategoryId = itemCategoryId.Trim(); - var user = _userService.GetByEmail(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, false), false, false); + var user = _userService.GetByUsername(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, false), false, false); var (walletStatus, walletBalance) = await _walletService.GetWalletStatusAndBalance(user.Id); @@ -189,6 +189,9 @@ public async Task BuyItem(string storeId, string itemCategoryId) if (string.IsNullOrEmpty(walletBalance.WalletId)) throw new InvalidOperationException($"Wallet id expected with status '{walletStatus}'"); + if (string.IsNullOrEmpty(walletBalance.WalletUsername)) + throw new InvalidOperationException($"Wallet username expected with status '{walletStatus}'"); + //find the 1st available item for the specified store and item category var storeItems = await _marketplaceProviderClient.ListStoreItems(storeId, itemCategoryId, 1, 0); if (storeItems.Count == 0) @@ -216,7 +219,7 @@ public async Task BuyItem(string storeId, string itemCategoryId) await _transactionLogRepository.Create(transactionExisting); //create a new reservation - transaction = await BuyItemTransactionReserve(user, walletBalance.WalletId, itemCategoryId, storeItem); + transaction = await BuyItemTransactionReserve(user.Id, walletBalance.WalletUsername, walletBalance.WalletId, itemCategoryId, storeItem); } else //not expired; re-use existing reservation @@ -225,20 +228,20 @@ public async Task BuyItem(string storeId, string itemCategoryId) else { //no existing reservation; create a new one - transaction = await BuyItemTransactionReserve(user, walletBalance.WalletId, itemCategoryId, storeItem); + transaction = await BuyItemTransactionReserve(user.Id, walletBalance.WalletUsername, walletBalance.WalletId, itemCategoryId, storeItem); } - await BuyItemTransactionSold(transaction, user, walletBalance.WalletId); + await BuyItemTransactionSold(transaction, walletBalance.WalletUsername, walletBalance.WalletId); } /// /// Reserve item and log transaction; with failure attempt to reset / release reservation /// - private async Task BuyItemTransactionReserve(User user, string walletId, string itemCategoryId, StoreItem storeItem) + private async Task BuyItemTransactionReserve(Guid userId, string walletUsername, string walletId, string itemCategoryId, StoreItem storeItem) { var result = new TransactionLog { - UserId = user.Id, + UserId = userId, ItemCategoryId = itemCategoryId, ItemId = storeItem.Id, Amount = storeItem.Amount @@ -247,7 +250,7 @@ private async Task BuyItemTransactionReserve(User user, string w var reserved = false; try { - result.TransactionId = await _marketplaceProviderClient.ItemReserve(walletId, user.Email, storeItem.Id); + result.TransactionId = await _marketplaceProviderClient.ItemReserve(walletId, walletUsername, storeItem.Id); reserved = true; result.StatusId = _transactionStatusService.GetByName(TransactionStatus.Reserved.ToString()).Id; @@ -267,7 +270,7 @@ private async Task BuyItemTransactionReserve(User user, string w /// /// Mark item as sold and log transaction; with failure attempt to reset / release reservation provided not sold /// - private async Task BuyItemTransactionSold(TransactionLog transaction, User user, string walletId) + private async Task BuyItemTransactionSold(TransactionLog transaction, string walletUsername, string walletId) { var sold = false; try @@ -280,7 +283,7 @@ await _executionStrategyService.ExecuteInExecutionStrategyAsync(async () => transaction.Status = TransactionStatus.Sold; transaction = await _transactionLogRepository.Create(transaction); - await _marketplaceProviderClient.ItemSold(walletId, user.Email, transaction.ItemId, transaction.TransactionId); + await _marketplaceProviderClient.ItemSold(walletId, walletUsername, transaction.ItemId, transaction.TransactionId); sold = true; scope.Complete(); diff --git a/src/api/src/domain/Yoma.Core.Domain/MyOpportunity/Extensions/MyOpportunityExtensions.cs b/src/api/src/domain/Yoma.Core.Domain/MyOpportunity/Extensions/MyOpportunityExtensions.cs index 20044ebb7..e4b72bae9 100644 --- a/src/api/src/domain/Yoma.Core.Domain/MyOpportunity/Extensions/MyOpportunityExtensions.cs +++ b/src/api/src/domain/Yoma.Core.Domain/MyOpportunity/Extensions/MyOpportunityExtensions.cs @@ -14,7 +14,9 @@ public static MyOpportunityInfo ToInfo(this Models.MyOpportunity value) { Id = value.Id, UserId = value.UserId, + Username = value.Username, UserEmail = value.UserEmail, + UserPhoneNumer = value.UserPhoneNumber, UserDisplayName = value.UserDisplayName, UserCountry = value.UserCountry, UserEducation = value.UserEducation, diff --git a/src/api/src/domain/Yoma.Core.Domain/MyOpportunity/Models/MyOpportunity.cs b/src/api/src/domain/Yoma.Core.Domain/MyOpportunity/Models/MyOpportunity.cs index b57527387..985b9c7e1 100644 --- a/src/api/src/domain/Yoma.Core.Domain/MyOpportunity/Models/MyOpportunity.cs +++ b/src/api/src/domain/Yoma.Core.Domain/MyOpportunity/Models/MyOpportunity.cs @@ -11,7 +11,11 @@ public class MyOpportunity public Guid UserId { get; set; } - public string UserEmail { get; set; } + public string Username { get; set; } + + public string? UserEmail { get; set; } + + public string? UserPhoneNumber { get; set; } public string UserDisplayName { get; set; } diff --git a/src/api/src/domain/Yoma.Core.Domain/MyOpportunity/Models/MyOpportunityInfo.cs b/src/api/src/domain/Yoma.Core.Domain/MyOpportunity/Models/MyOpportunityInfo.cs index 1077bf22c..5173e9b1a 100644 --- a/src/api/src/domain/Yoma.Core.Domain/MyOpportunity/Models/MyOpportunityInfo.cs +++ b/src/api/src/domain/Yoma.Core.Domain/MyOpportunity/Models/MyOpportunityInfo.cs @@ -12,11 +12,17 @@ public class MyOpportunityInfo [Ignore] public Guid UserId { get; set; } + [Ignore] + public string Username { get; set; } + [Name("Student Email")] - public string UserEmail { get; set; } + public string? UserEmail { get; set; } + + [Name("Student Phone Number")] + public string? UserPhoneNumer { get; set; } [Name("Student Display Name")] - public string? UserDisplayName { get; set; } + public string UserDisplayName { get; set; } [Name("Student Country")] public string? UserCountry { get; set; } diff --git a/src/api/src/domain/Yoma.Core.Domain/MyOpportunity/Models/MyOpportunityResponseVerifyCompletedExternal.cs b/src/api/src/domain/Yoma.Core.Domain/MyOpportunity/Models/MyOpportunityResponseVerifyCompletedExternal.cs index ea17a7c3b..2e7406c29 100644 --- a/src/api/src/domain/Yoma.Core.Domain/MyOpportunity/Models/MyOpportunityResponseVerifyCompletedExternal.cs +++ b/src/api/src/domain/Yoma.Core.Domain/MyOpportunity/Models/MyOpportunityResponseVerifyCompletedExternal.cs @@ -7,7 +7,11 @@ public class MyOpportunityResponseVerifyCompletedExternal public class MyOpportunityResponseVerifyStatusExternalUser { - public string Email { get; set; } + public string Username { get; set; } + + public string? Email { get; set; } + + public string? PhoneNumber { get; set; } public DateTimeOffset? DateCompleted { get; set; } } diff --git a/src/api/src/domain/Yoma.Core.Domain/MyOpportunity/Models/MyOpportunityResponseVerifyFinalizeBatch.cs b/src/api/src/domain/Yoma.Core.Domain/MyOpportunity/Models/MyOpportunityResponseVerifyFinalizeBatch.cs index 80141d763..fa60d66a1 100644 --- a/src/api/src/domain/Yoma.Core.Domain/MyOpportunity/Models/MyOpportunityResponseVerifyFinalizeBatch.cs +++ b/src/api/src/domain/Yoma.Core.Domain/MyOpportunity/Models/MyOpportunityResponseVerifyFinalizeBatch.cs @@ -17,7 +17,7 @@ public class MyOpportunityResponseVerifyFinalizeBatchItem public Guid UserId { get; set; } - public string? UserDisplayName { get; set; } + public string UserDisplayName { get; set; } public bool Success => Failure == null; diff --git a/src/api/src/domain/Yoma.Core.Domain/MyOpportunity/Services/MyOpportunityBackgroundService.cs b/src/api/src/domain/Yoma.Core.Domain/MyOpportunity/Services/MyOpportunityBackgroundService.cs index 4333c900f..c38c0b20e 100644 --- a/src/api/src/domain/Yoma.Core.Domain/MyOpportunity/Services/MyOpportunityBackgroundService.cs +++ b/src/api/src/domain/Yoma.Core.Domain/MyOpportunity/Services/MyOpportunityBackgroundService.cs @@ -8,9 +8,9 @@ using Yoma.Core.Domain.Core.Helpers; using Yoma.Core.Domain.Core.Interfaces; using Yoma.Core.Domain.Core.Models; -using Yoma.Core.Domain.EmailProvider; -using Yoma.Core.Domain.EmailProvider.Interfaces; -using Yoma.Core.Domain.EmailProvider.Models; +using Yoma.Core.Domain.Notification; +using Yoma.Core.Domain.Notification.Interfaces; +using Yoma.Core.Domain.Notification.Models; using Yoma.Core.Domain.MyOpportunity.Interfaces; using Yoma.Core.Domain.MyOpportunity.Models; using Yoma.Core.Domain.Opportunity; @@ -29,9 +29,8 @@ public class MyOpportunityBackgroundService : IMyOpportunityBackgroundService private readonly IMyOpportunityVerificationStatusService _myOpportunityVerificationStatusService; private readonly IMyOpportunityActionService _myOpportunityActionService; private readonly IOpportunityService _opportunityService; - private readonly IEmailURLFactory _emailURLFactory; - private readonly IEmailPreferenceFilterService _emailPreferenceFilterService; - private readonly IEmailProviderClient _emailProviderClient; + private readonly INotificationURLFactory _notificationURLFactory; + private readonly INotificationDeliveryService _notificationDeliveryService; private readonly IRepositoryBatchedWithNavigation _myOpportunityRepository; private readonly IRepository _myOpportunityVerificationRepository; private readonly IDistributedLockService _distributedLockService; @@ -48,9 +47,8 @@ public MyOpportunityBackgroundService(ILogger lo IMyOpportunityVerificationStatusService myOpportunityVerificationStatusService, IMyOpportunityActionService myOpportunityActionService, IOpportunityService opportunityService, - IEmailURLFactory emailURLFactory, - IEmailPreferenceFilterService emailPreferenceFilterService, - IEmailProviderClientFactory emailProviderClientFactory, + INotificationURLFactory notificationURLFactory, + INotificationDeliveryService notificationDeliveryService, IRepositoryBatchedWithNavigation myOpportunityRepository, IRepository myOpportunityVerificationRepository, IDistributedLockService distributedLockService) @@ -63,9 +61,8 @@ public MyOpportunityBackgroundService(ILogger lo _myOpportunityVerificationStatusService = myOpportunityVerificationStatusService; _myOpportunityActionService = myOpportunityActionService; _opportunityService = opportunityService; - _emailURLFactory = emailURLFactory; - _emailPreferenceFilterService = emailPreferenceFilterService; - _emailProviderClient = emailProviderClientFactory.CreateClient(); + _notificationURLFactory = notificationURLFactory; + _notificationDeliveryService = notificationDeliveryService; _myOpportunityRepository = myOpportunityRepository; _myOpportunityVerificationRepository = myOpportunityVerificationRepository; _distributedLockService = distributedLockService; @@ -114,48 +111,45 @@ public async Task ProcessVerificationRejection() items = await _myOpportunityRepository.Update(items); - var groupedMyOpportunities = items.GroupBy(item => new { item.UserEmail, item.UserDisplayName }); + var groupedMyOpportunities = items.GroupBy(item => new { item.Username, item.UserEmail, item.UserPhoneNumber, item.UserDisplayName }); - var emailType = EmailType.Opportunity_Verification_Rejected; + var notificationType = NotificationType.Opportunity_Verification_Rejected; foreach (var group in groupedMyOpportunities) { try { - var recipients = new List + var recipients = new List { - new() { Email = group.Key.UserEmail, DisplayName = group.Key.UserDisplayName } + new() { Username = group.Key.Username, PhoneNumber = group.Key.UserPhoneNumber, Email = group.Key.UserEmail, DisplayName = group.Key.UserDisplayName } }; - recipients = _emailPreferenceFilterService.FilterRecipients(emailType, recipients); - if (recipients == null || recipients.Count == 0) continue; - - var data = new EmailOpportunityVerification + var data = new NotificationOpportunityVerification { - YoIDURL = _emailURLFactory.OpportunityVerificationYoIDURL(emailType), + YoIDURL = _notificationURLFactory.OpportunityVerificationYoIDURL(notificationType), Opportunities = [] }; foreach (var myOp in group) { - data.Opportunities.Add(new EmailOpportunityVerificationItem + data.Opportunities.Add(new NotificationOpportunityVerificationItem { Title = myOp.OpportunityTitle, DateStart = myOp.DateStart, DateEnd = myOp.DateEnd, Comment = myOp.CommentVerification, - URL = _emailURLFactory.OpportunityVerificationItemURL(emailType, myOp.OpportunityId, null), + URL = _notificationURLFactory.OpportunityVerificationItemURL(notificationType, myOp.OpportunityId, null), ZltoReward = myOp.ZltoReward, YomaReward = myOp.YomaReward }); } - await _emailProviderClient.Send(emailType, recipients, data); + await _notificationDeliveryService.Send(notificationType, recipients, data); - _logger.LogInformation("Successfully send email"); + _logger.LogInformation("Successfully send notification"); } catch (Exception ex) { - _logger.LogError(ex, "Failed to send email"); + _logger.LogError(ex, "Failed to send notification"); } } diff --git a/src/api/src/domain/Yoma.Core.Domain/MyOpportunity/Services/MyOpportunityService.cs b/src/api/src/domain/Yoma.Core.Domain/MyOpportunity/Services/MyOpportunityService.cs index 0393f2340..fb0c24b9a 100644 --- a/src/api/src/domain/Yoma.Core.Domain/MyOpportunity/Services/MyOpportunityService.cs +++ b/src/api/src/domain/Yoma.Core.Domain/MyOpportunity/Services/MyOpportunityService.cs @@ -14,9 +14,9 @@ using Yoma.Core.Domain.Core.Helpers; using Yoma.Core.Domain.Core.Interfaces; using Yoma.Core.Domain.Core.Models; -using Yoma.Core.Domain.EmailProvider; -using Yoma.Core.Domain.EmailProvider.Interfaces; -using Yoma.Core.Domain.EmailProvider.Models; +using Yoma.Core.Domain.Notification; +using Yoma.Core.Domain.Notification.Interfaces; +using Yoma.Core.Domain.Notification.Models; using Yoma.Core.Domain.Entity; using Yoma.Core.Domain.Entity.Helpers; using Yoma.Core.Domain.Entity.Interfaces; @@ -52,9 +52,8 @@ public class MyOpportunityService : IMyOpportunityService private readonly ISSICredentialService _ssiCredentialService; private readonly IRewardService _rewardService; private readonly ILinkService _linkService; - private readonly IEmailURLFactory _emailURLFactory; - private readonly IEmailPreferenceFilterService _emailPreferenceFilterService; - private readonly IEmailProviderClient _emailProviderClient; + private readonly INotificationURLFactory _notificationURLFactory; + private readonly INotificationDeliveryService _notificationDeliveryService; private readonly MyOpportunitySearchFilterValidator _myOpportunitySearchFilterValidator; private readonly MyOpportunityRequestValidatorVerify _myOpportunityRequestValidatorVerify; private readonly MyOpportunityRequestValidatorVerifyFinalize _myOpportunityRequestValidatorVerifyFinalize; @@ -64,7 +63,7 @@ public class MyOpportunityService : IMyOpportunityService private readonly IExecutionStrategyService _executionStrategyService; private const int List_Aggregated_Opportunity_By_Limit = 100; - private const string PlaceholderValue_HiddenEmail = "hidden"; + private const string PlaceholderValue_HiddenDetails = "hidden"; private static readonly VerificationType[] VerificationTypes_Downloadable = [VerificationType.FileUpload, VerificationType.Picture, VerificationType.VoiceNote]; #endregion @@ -84,9 +83,8 @@ public MyOpportunityService(ILogger logger, ISSICredentialService ssiCredentialService, IRewardService rewardService, ILinkService linkService, - IEmailURLFactory emailURLFactory, - IEmailPreferenceFilterService emailPreferenceFilterService, - IEmailProviderClientFactory emailProviderClientFactory, + INotificationURLFactory notificationURLFactory, + INotificationDeliveryService notificationDeliveryService, MyOpportunitySearchFilterValidator myOpportunitySearchFilterValidator, MyOpportunityRequestValidatorVerify myOpportunityRequestValidatorVerify, MyOpportunityRequestValidatorVerifyFinalize myOpportunityRequestValidatorVerifyFinalize, @@ -109,9 +107,8 @@ public MyOpportunityService(ILogger logger, _ssiCredentialService = ssiCredentialService; _rewardService = rewardService; _linkService = linkService; - _emailURLFactory = emailURLFactory; - _emailPreferenceFilterService = emailPreferenceFilterService; - _emailProviderClient = emailProviderClientFactory.CreateClient(); + _notificationURLFactory = notificationURLFactory; + _notificationDeliveryService = notificationDeliveryService; _myOpportunitySearchFilterValidator = myOpportunitySearchFilterValidator; _myOpportunityRequestValidatorVerify = myOpportunityRequestValidatorVerify; _myOpportunityRequestValidatorVerifyFinalize = myOpportunityRequestValidatorVerifyFinalize; @@ -223,7 +220,7 @@ public async Task DownloadVerificationFiles(Guid opportunityId, List< var opportunity = _opportunityService.GetById(opportunityId, false, false, false); - var user = _userService.GetByEmail(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, false), false, false); + var user = _userService.GetByUsername(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, false), false, false); var actionVerificationId = _myOpportunityActionService.GetByName(Action.Verification.ToString()).Id; var myOpportunity = _myOpportunityRepository.Query(true).SingleOrDefault( @@ -280,7 +277,7 @@ public MyOpportunityResponseVerifyStatus GetVerificationStatus(Guid opportunityI { var opportunity = _opportunityService.GetById(opportunityId, false, false, false); - var user = _userService.GetByEmail(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, false), false, false); + var user = _userService.GetByUsername(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, false), false, false); var actionVerificationId = _myOpportunityActionService.GetByName(Action.Verification.ToString()).Id; var myOpportunity = _myOpportunityRepository.Query(false).SingleOrDefault(o => o.UserId == user.Id && o.OpportunityId == opportunity.Id && o.ActionId == actionVerificationId); @@ -310,10 +307,12 @@ public MyOpportunityResponseVerifyCompletedExternal GetVerificationCompletedExte var users = completions.Where(completion => SettingsHelper.GetValue( _userService.GetSettingsInfo(completion.UserSettings), - Setting.User_Share_Email_With_Partners.ToString()) == true + Setting.User_Share_Contact_Info_With_Partners.ToString()) == true ).Select(completion => new MyOpportunityResponseVerifyStatusExternalUser { + Username = completion.Username, Email = completion.UserEmail, + PhoneNumber = completion.UserPhoneNumber, DateCompleted = completion.DateCompleted }).ToList(); @@ -326,7 +325,7 @@ public MyOpportunitySearchResults Search(MyOpportunitySearchFilter filter, User? //filter validated by SearchAdmin - user ??= _userService.GetByEmail(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, false), false, false); + user ??= _userService.GetByUsername(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, false), false, false); var filterInternal = new MyOpportunitySearchFilterAdmin { @@ -345,7 +344,7 @@ public MyOpportunitySearchResults Search(MyOpportunitySearchFilter filter, User? public TimeIntervalSummary GetSummary() { - var user = _userService.GetByEmail(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, false), false, false); + var user = _userService.GetByUsername(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, false), false, false); var filterInternal = new MyOpportunitySearchFilterAdmin { @@ -565,8 +564,9 @@ public MyOpportunitySearchResults Search(MyOpportunitySearchFilterAdmin filter, foreach (var item in result.Items) { - if (SettingsHelper.GetValue(_userService.GetSettingsInfo(item.UserSettings), Setting.User_Share_Email_With_Partners.ToString()) == true) continue; - item.UserEmail = PlaceholderValue_HiddenEmail; + if (SettingsHelper.GetValue(_userService.GetSettingsInfo(item.UserSettings), Setting.User_Share_Contact_Info_With_Partners.ToString()) == true) continue; + item.UserEmail = PlaceholderValue_HiddenDetails; + item.UserPhoneNumer = PlaceholderValue_HiddenDetails; } var config = new CsvConfiguration(System.Globalization.CultureInfo.CurrentCulture); @@ -589,7 +589,7 @@ public async Task PerformActionViewed(Guid opportunityId) if (!opportunity.Published) throw new ValidationException(PerformActionNotPossibleValidationMessage(opportunity, "cannot be actioned")); - var user = _userService.GetByEmail(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, false), false, false); + var user = _userService.GetByUsername(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, false), false, false); var actionViewedId = _myOpportunityActionService.GetByName(Action.Viewed.ToString()).Id; @@ -615,7 +615,7 @@ public async Task PerformActionNavigatedExternalLink(Guid opportunityId) if (!opportunity.Published) throw new ValidationException(PerformActionNotPossibleValidationMessage(opportunity, "cannot be actioned")); - var user = _userService.GetByEmail(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, false), false, false); + var user = _userService.GetByUsername(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, false), false, false); var actionViewedId = _myOpportunityActionService.GetByName(Action.NavigatedExternalLink.ToString()).Id; @@ -640,7 +640,7 @@ public bool ActionedSaved(Guid opportunityId) var opportunity = _opportunityService.GetById(opportunityId, false, true, false); if (!opportunity.Published) return false; - var user = _userService.GetByEmail(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, false), false, false); + var user = _userService.GetByUsername(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, false), false, false); var actionSavedId = _myOpportunityActionService.GetByName(Action.Saved.ToString()).Id; @@ -656,7 +656,7 @@ public async Task PerformActionSaved(Guid opportunityId) if (!opportunity.Published) throw new ValidationException(PerformActionNotPossibleValidationMessage(opportunity, "cannot be actioned")); - var user = _userService.GetByEmail(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, false), false, false); + var user = _userService.GetByUsername(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, false), false, false); var actionSavedId = _myOpportunityActionService.GetByName(Action.Saved.ToString()).Id; @@ -682,7 +682,7 @@ public async Task PerformActionSavedRemove(Guid opportunityId) if (!opportunity.Published) throw new ValidationException(PerformActionNotPossibleValidationMessage(opportunity, "cannot be actioned")); - var user = _userService.GetByEmail(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, false), false, false); + var user = _userService.GetByUsername(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, false), false, false); var actionSavedId = _myOpportunityActionService.GetByName(Action.Saved.ToString()).Id; var myOpportunity = _myOpportunityRepository.Query(false).SingleOrDefault(o => o.UserId == user.Id && o.OpportunityId == opportunity.Id && o.ActionId == actionSavedId); @@ -703,7 +703,7 @@ public async Task PerformActionSendForVerificationManual(Guid userId, Guid oppor public async Task PerformActionSendForVerificationManual(Guid opportunityId, MyOpportunityRequestVerify request) { - var user = _userService.GetByEmail(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, false), false, false); + var user = _userService.GetByUsername(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, false), false, false); await PerformActionSendForVerificationManual(user, opportunityId, request); } @@ -718,7 +718,7 @@ public async Task PerformActionInstantVerificationManual(Guid linkId) _linkService.AssertActive(link.Id); //send for verification - var user = _userService.GetByEmail(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, false), false, false); + var user = _userService.GetByUsername(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, false), false, false); var opportunity = _opportunityService.GetById(link.EntityId, true, true, false); await _executionStrategyService.ExecuteInExecutionStrategyAsync(async () => @@ -738,7 +738,7 @@ await _executionStrategyService.ExecuteInExecutionStrategyAsync(async () => public async Task PerformActionSendForVerificationManualDelete(Guid opportunityId) { - var user = _userService.GetByEmail(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, false), false, false); + var user = _userService.GetByUsername(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, false), false, false); //opportunity can be updated whilst active, and can result in disabling support for verification; allow deletion provided verification is pending even if no longer supported //similar logic provided sent for verification prior to update that resulted in disabling support for verification i.e. enabled, method, types, 'published' status etc. @@ -746,7 +746,7 @@ public async Task PerformActionSendForVerificationManualDelete(Guid opportunityI var actionVerificationId = _myOpportunityActionService.GetByName(Action.Verification.ToString()).Id; var myOpportunity = _myOpportunityRepository.Query(true).SingleOrDefault(o => o.UserId == user.Id && o.OpportunityId == opportunity.Id && o.ActionId == actionVerificationId) - ?? throw new ValidationException($"Opportunity '{opportunity.Title}' has not been sent for verification for user '{user.Email}'"); + ?? throw new ValidationException($"Opportunity '{opportunity.Title}' has not been sent for verification for user '{user.Username}'"); if (myOpportunity.VerificationStatus != VerificationStatus.Pending) throw new ValidationException($"Verification is not {VerificationStatus.Pending.ToString().ToLower()} for 'my' opportunity '{opportunity.Title}'"); @@ -827,7 +827,7 @@ public async Task FinalizeVerification OpportunityId = item.OpportunityId, OpportunityTitle = opportunity.Title, UserId = item.UserId, - UserDisplayName = user.DisplayName, + UserDisplayName = user.DisplayName ?? user.Username, Failure = null }; resultItems.Add(successItem); @@ -963,7 +963,7 @@ private async Task FinalizeVerificationManual(User user, Opportunity.Models.Oppo var actionVerificationId = _myOpportunityActionService.GetByName(Action.Verification.ToString()).Id; var item = _myOpportunityRepository.Query(false).SingleOrDefault(o => o.UserId == user.Id && o.OpportunityId == opportunity.Id && o.ActionId == actionVerificationId) - ?? throw new ValidationException($"Opportunity '{opportunity.Title}' has not been sent for verification for user '{user.Email}'"); + ?? throw new ValidationException($"Opportunity '{opportunity.Title}' has not been sent for verification for user '{user.Username}'"); if (item.VerificationStatus != VerificationStatus.Pending) throw new ValidationException($"Verification is not {VerificationStatus.Pending.ToString().ToLower()} for 'my' opportunity '{opportunity.Title}'"); @@ -972,7 +972,7 @@ private async Task FinalizeVerificationManual(User user, Opportunity.Models.Oppo var statusId = _myOpportunityVerificationStatusService.GetByName(status.ToString()).Id; - EmailType? emailType = null; + NotificationType? notificationType = null; await _executionStrategyService.ExecuteInExecutionStrategyAsync(async () => { using var scope = new TransactionScope(TransactionScopeOption.Required, TransactionScopeAsyncFlowOption.Enabled); @@ -983,7 +983,7 @@ await _executionStrategyService.ExecuteInExecutionStrategyAsync(async () => switch (status) { case VerificationStatus.Rejected: - emailType = EmailType.Opportunity_Verification_Rejected; + notificationType = NotificationType.Opportunity_Verification_Rejected; break; case VerificationStatus.Completed: @@ -1020,7 +1020,7 @@ await _executionStrategyService.ExecuteInExecutionStrategyAsync(async () => else if (result.YomaRewardReduced == true) item.CommentVerification = CommentVerificationAppendInfo(item.CommentVerification, "Yoma partially awarded due to insufficient reward pool"); - emailType = EmailType.Opportunity_Verification_Completed; + notificationType = NotificationType.Opportunity_Verification_Completed; break; default: @@ -1032,10 +1032,10 @@ await _executionStrategyService.ExecuteInExecutionStrategyAsync(async () => scope.Complete(); }); - if (!emailType.HasValue) - throw new InvalidOperationException($"Email type expected"); + if (!notificationType.HasValue) + throw new InvalidOperationException($"Notification type expected"); - await SendEmail(item, emailType.Value); + await SendNotification(item, notificationType.Value); } @@ -1187,45 +1187,49 @@ private async Task PerformActionSendForVerificationManual(User user, Guid opport await PerformActionSendForVerificationManualProcessVerificationTypes(request, opportunity, myOpportunity, isNew); - //used by emailer + //used by notifications + myOpportunity.UserPhoneNumber = user.PhoneNumber; myOpportunity.UserEmail = user.Email; - myOpportunity.UserDisplayName = user.DisplayName; + myOpportunity.UserDisplayName = user.DisplayName ?? user.Username; myOpportunity.OpportunityTitle = opportunity.Title; myOpportunity.OrganizationId = opportunity.OrganizationId; myOpportunity.ZltoReward = opportunity.ZltoReward; myOpportunity.YomaReward = opportunity.YomaReward; - if (request.InstantVerification) return; //with instant-verifications verification pending emails are not sent + if (request.InstantVerification) return; //with instant-verifications verification pending notifications are not sent //sent to youth - await SendEmail(myOpportunity, EmailType.Opportunity_Verification_Pending); + await SendNotification(myOpportunity, NotificationType.Opportunity_Verification_Pending); //sent to organization admins - await SendEmail(myOpportunity, EmailType.Opportunity_Verification_Pending_Admin); + await SendNotification(myOpportunity, NotificationType.Opportunity_Verification_Pending_Admin); } - private async Task SendEmail(Models.MyOpportunity myOpportunity, EmailType type) + private async Task SendNotification(Models.MyOpportunity myOpportunity, NotificationType type) { try { - List? recipients = null; + List? recipients = null; + recipients = type switch { - EmailType.Opportunity_Verification_Rejected or EmailType.Opportunity_Verification_Completed or EmailType.Opportunity_Verification_Pending => [ - new() { Email = myOpportunity.UserEmail, DisplayName = myOpportunity.UserDisplayName } - ], - EmailType.Opportunity_Verification_Pending_Admin => _organizationService.ListAdmins(myOpportunity.OrganizationId, false, false) - .Select(o => new EmailRecipient { Email = o.Email, DisplayName = o.DisplayName }).ToList(), + NotificationType.Opportunity_Verification_Rejected or + NotificationType.Opportunity_Verification_Completed or + NotificationType.Opportunity_Verification_Pending => + [new() { Username = myOpportunity.Username, PhoneNumber = myOpportunity.UserPhoneNumber, Email = myOpportunity.UserEmail, DisplayName = myOpportunity.UserDisplayName }], + + NotificationType.Opportunity_Verification_Pending_Admin => + _organizationService.ListAdmins(myOpportunity.OrganizationId, false, false) + .Select(o => new NotificationRecipient { Username = o.Username, PhoneNumber = o.PhoneNumber, Email = o.Email, DisplayName = o.DisplayName }) + .ToList(), + _ => throw new ArgumentOutOfRangeException(nameof(type), $"Type of '{type}' not supported"), }; - recipients = _emailPreferenceFilterService.FilterRecipients(type, recipients); - if (recipients == null || recipients.Count == 0) return; - - var data = new EmailOpportunityVerification + var data = new NotificationOpportunityVerification { - YoIDURL = _emailURLFactory.OpportunityVerificationYoIDURL(type), - VerificationURL = _emailURLFactory.OpportunityVerificationURL(type, myOpportunity.OrganizationId), + YoIDURL = _notificationURLFactory.OpportunityVerificationYoIDURL(type), + VerificationURL = _notificationURLFactory.OpportunityVerificationURL(type, myOpportunity.OrganizationId), Opportunities = [ new() { @@ -1233,20 +1237,20 @@ private async Task SendEmail(Models.MyOpportunity myOpportunity, EmailType type) DateStart = myOpportunity.DateStart, DateEnd = myOpportunity.DateEnd, Comment = myOpportunity.CommentVerification, - URL = _emailURLFactory.OpportunityVerificationItemURL(type, myOpportunity.OpportunityId, myOpportunity.OrganizationId), + URL = _notificationURLFactory.OpportunityVerificationItemURL(type, myOpportunity.OpportunityId, myOpportunity.OrganizationId), ZltoReward = myOpportunity.ZltoReward, YomaReward = myOpportunity.YomaReward } ] }; - await _emailProviderClient.Send(type, recipients, data); + await _notificationDeliveryService.Send(type, recipients, data); - _logger.LogInformation("Successfully send email"); + _logger.LogInformation("Successfully send notification"); } catch (Exception ex) { - _logger.LogError(ex, "Failed to send email"); + _logger.LogError(ex, "Failed to send notification"); } } diff --git a/src/api/src/domain/Yoma.Core.Domain/EmailProvider/Enumerations.cs b/src/api/src/domain/Yoma.Core.Domain/Notification/Enumerations.cs similarity index 87% rename from src/api/src/domain/Yoma.Core.Domain/EmailProvider/Enumerations.cs rename to src/api/src/domain/Yoma.Core.Domain/Notification/Enumerations.cs index bbb6dec6d..5c64af106 100644 --- a/src/api/src/domain/Yoma.Core.Domain/EmailProvider/Enumerations.cs +++ b/src/api/src/domain/Yoma.Core.Domain/Notification/Enumerations.cs @@ -1,6 +1,6 @@ -namespace Yoma.Core.Domain.EmailProvider +namespace Yoma.Core.Domain.Notification { - public enum EmailType + public enum NotificationType { Organization_Approval_Requested, //sent to admin Organization_Approval_Approved, //sent to organization admin @@ -18,4 +18,10 @@ public enum EmailType ActionLink_Verify_Approval_Declined, //sent to organization admin Opportunity_Published //sent to youth } + + public enum MessageType + { + SMS, + WhatsApp + } } diff --git a/src/api/src/domain/Yoma.Core.Domain/Notification/Interfaces/IEmailProviderClient.cs b/src/api/src/domain/Yoma.Core.Domain/Notification/Interfaces/IEmailProviderClient.cs new file mode 100644 index 000000000..175e50c64 --- /dev/null +++ b/src/api/src/domain/Yoma.Core.Domain/Notification/Interfaces/IEmailProviderClient.cs @@ -0,0 +1,13 @@ +using Yoma.Core.Domain.Notification.Models; + +namespace Yoma.Core.Domain.Notification.Interfaces +{ + public interface IEmailProviderClient + { + Task Send(NotificationType type, List recipients, T data) + where T : NotificationBase; + + Task Send(NotificationType type, List<(List Recipients, T Data)> recipientDataGroups) + where T : NotificationBase; + } +} diff --git a/src/api/src/domain/Yoma.Core.Domain/EmailProvider/Interfaces/IEmailProviderClientFactory.cs b/src/api/src/domain/Yoma.Core.Domain/Notification/Interfaces/IEmailProviderClientFactory.cs similarity index 65% rename from src/api/src/domain/Yoma.Core.Domain/EmailProvider/Interfaces/IEmailProviderClientFactory.cs rename to src/api/src/domain/Yoma.Core.Domain/Notification/Interfaces/IEmailProviderClientFactory.cs index 70f530c8c..d6161eb67 100644 --- a/src/api/src/domain/Yoma.Core.Domain/EmailProvider/Interfaces/IEmailProviderClientFactory.cs +++ b/src/api/src/domain/Yoma.Core.Domain/Notification/Interfaces/IEmailProviderClientFactory.cs @@ -1,4 +1,4 @@ -namespace Yoma.Core.Domain.EmailProvider.Interfaces +namespace Yoma.Core.Domain.Notification.Interfaces { public interface IEmailProviderClientFactory { diff --git a/src/api/src/domain/Yoma.Core.Domain/Notification/Interfaces/IMessageProviderClient.cs b/src/api/src/domain/Yoma.Core.Domain/Notification/Interfaces/IMessageProviderClient.cs new file mode 100644 index 000000000..2e39928e8 --- /dev/null +++ b/src/api/src/domain/Yoma.Core.Domain/Notification/Interfaces/IMessageProviderClient.cs @@ -0,0 +1,13 @@ +using Yoma.Core.Domain.Notification.Models; + +namespace Yoma.Core.Domain.Notification.Interfaces +{ + public interface IMessageProviderClient + { + Task Send(MessageType deliveryType, NotificationType notificationType, List recipients, T data) + where T : NotificationBase; + + Task Send(MessageType deliveryType, NotificationType notificationType, List<(List Recipients, T Data)> recipientDataGroups) + where T : NotificationBase; + } +} diff --git a/src/api/src/domain/Yoma.Core.Domain/Notification/Interfaces/IMessageProviderClientFactory.cs b/src/api/src/domain/Yoma.Core.Domain/Notification/Interfaces/IMessageProviderClientFactory.cs new file mode 100644 index 000000000..70afb3662 --- /dev/null +++ b/src/api/src/domain/Yoma.Core.Domain/Notification/Interfaces/IMessageProviderClientFactory.cs @@ -0,0 +1,7 @@ +namespace Yoma.Core.Domain.Notification.Interfaces +{ + public interface IMessageProviderClientFactory + { + IMessageProviderClient CreateClient(); + } +} diff --git a/src/api/src/domain/Yoma.Core.Domain/Notification/Interfaces/INotificationDeliveryService.cs b/src/api/src/domain/Yoma.Core.Domain/Notification/Interfaces/INotificationDeliveryService.cs new file mode 100644 index 000000000..abfd3e420 --- /dev/null +++ b/src/api/src/domain/Yoma.Core.Domain/Notification/Interfaces/INotificationDeliveryService.cs @@ -0,0 +1,13 @@ +using Yoma.Core.Domain.Notification.Models; + +namespace Yoma.Core.Domain.Notification.Interfaces +{ + public interface INotificationDeliveryService + { + Task Send(NotificationType type, List? recipients, T data) + where T : NotificationBase; + + Task Send(NotificationType type, List<(List Recipients, T Data)>? recipientDataGroups) + where T : NotificationBase; + } +} diff --git a/src/api/src/domain/Yoma.Core.Domain/Notification/Interfaces/INotificationPreferenceFilterService.cs b/src/api/src/domain/Yoma.Core.Domain/Notification/Interfaces/INotificationPreferenceFilterService.cs new file mode 100644 index 000000000..95717d6c6 --- /dev/null +++ b/src/api/src/domain/Yoma.Core.Domain/Notification/Interfaces/INotificationPreferenceFilterService.cs @@ -0,0 +1,9 @@ +using Yoma.Core.Domain.Notification.Models; + +namespace Yoma.Core.Domain.Notification.Interfaces +{ + public interface INotificationPreferenceFilterService + { + List? FilterRecipients(NotificationType type, List? recipients); + } +} diff --git a/src/api/src/domain/Yoma.Core.Domain/Notification/Interfaces/INotificationURLFactory.cs b/src/api/src/domain/Yoma.Core.Domain/Notification/Interfaces/INotificationURLFactory.cs new file mode 100644 index 000000000..ed534b07a --- /dev/null +++ b/src/api/src/domain/Yoma.Core.Domain/Notification/Interfaces/INotificationURLFactory.cs @@ -0,0 +1,19 @@ +namespace Yoma.Core.Domain.Notification.Interfaces +{ + public interface INotificationURLFactory + { + string OrganizationApprovalItemURL(NotificationType emailType, Guid organizationId); + + string OpportunityVerificationItemURL(NotificationType emailType, Guid opportunityId, Guid? organizationId); + + string? OpportunityVerificationYoIDURL(NotificationType emailType); + + string? OpportunityVerificationURL(NotificationType emailType, Guid organizationId); + + string OpportunityExpirationItemURL(NotificationType emailType, Guid opportunityId, Guid organizationId); + + string OpportunityAnnouncedItemURL(NotificationType emailType, Guid opportunityId, Guid organizationId); + + string ActionLinkVerifyApprovalItemUrl(NotificationType emailType, Guid? organizationId); + } +} diff --git a/src/api/src/domain/Yoma.Core.Domain/EmailProvider/Models/EmailActionLinkVerify.cs b/src/api/src/domain/Yoma.Core.Domain/Notification/Models/NotificationActionLinkVerify.cs similarity index 85% rename from src/api/src/domain/Yoma.Core.Domain/EmailProvider/Models/EmailActionLinkVerify.cs rename to src/api/src/domain/Yoma.Core.Domain/Notification/Models/NotificationActionLinkVerify.cs index b6b325595..5e586abd9 100644 --- a/src/api/src/domain/Yoma.Core.Domain/EmailProvider/Models/EmailActionLinkVerify.cs +++ b/src/api/src/domain/Yoma.Core.Domain/Notification/Models/NotificationActionLinkVerify.cs @@ -1,8 +1,8 @@ using Newtonsoft.Json; -namespace Yoma.Core.Domain.EmailProvider.Models +namespace Yoma.Core.Domain.Notification.Models { - public class EmailActionLinkVerify : EmailBase + public class NotificationActionLinkVerify : NotificationBase { [JsonProperty("entityTypeDesc")] public string EntityTypeDesc { get; set; } @@ -11,10 +11,10 @@ public class EmailActionLinkVerify : EmailBase public string? YoIDURL { get; set; } [JsonProperty("items")] - public List Items { get; set; } + public List Items { get; set; } } - public class EmailActionLinkVerifyItem + public class NotificationActionLinkVerifyItem { [JsonProperty("title")] public string Title { get; set; } diff --git a/src/api/src/domain/Yoma.Core.Domain/EmailProvider/Models/EmailActionLinkVerifyApproval.cs b/src/api/src/domain/Yoma.Core.Domain/Notification/Models/NotificationActionLinkVerifyApproval.cs similarity index 66% rename from src/api/src/domain/Yoma.Core.Domain/EmailProvider/Models/EmailActionLinkVerifyApproval.cs rename to src/api/src/domain/Yoma.Core.Domain/Notification/Models/NotificationActionLinkVerifyApproval.cs index 3214b34ec..8b2f55ce6 100644 --- a/src/api/src/domain/Yoma.Core.Domain/EmailProvider/Models/EmailActionLinkVerifyApproval.cs +++ b/src/api/src/domain/Yoma.Core.Domain/Notification/Models/NotificationActionLinkVerifyApproval.cs @@ -1,14 +1,14 @@ using Newtonsoft.Json; -namespace Yoma.Core.Domain.EmailProvider.Models +namespace Yoma.Core.Domain.Notification.Models { - public class EmailActionLinkVerifyApproval : EmailBase + public class NotificationActionLinkVerifyApproval : NotificationBase { [JsonProperty("links")] - public List Links { get; set; } + public List Links { get; set; } } - public class EmailActionLinkVerifyApprovalItem + public class NotificationActionLinkVerifyApprovalItem { [JsonProperty("name")] public string Name { get; set; } diff --git a/src/api/src/domain/Yoma.Core.Domain/EmailProvider/Models/EmailBase.cs b/src/api/src/domain/Yoma.Core.Domain/Notification/Models/NotificationBase.cs similarity index 50% rename from src/api/src/domain/Yoma.Core.Domain/EmailProvider/Models/EmailBase.cs rename to src/api/src/domain/Yoma.Core.Domain/Notification/Models/NotificationBase.cs index 0da2c8879..24fe2e787 100644 --- a/src/api/src/domain/Yoma.Core.Domain/EmailProvider/Models/EmailBase.cs +++ b/src/api/src/domain/Yoma.Core.Domain/Notification/Models/NotificationBase.cs @@ -1,13 +1,16 @@ using Newtonsoft.Json; -namespace Yoma.Core.Domain.EmailProvider.Models +namespace Yoma.Core.Domain.Notification.Models { - public abstract class EmailBase + public abstract class NotificationBase { [JsonProperty("subjectSuffix")] public string SubjectSuffix { get; set; } [JsonProperty("recipientDisplayName")] public string RecipientDisplayName { get; set; } + + [JsonIgnore] + public virtual Dictionary ContentVariables => throw new NotImplementedException(); } } diff --git a/src/api/src/domain/Yoma.Core.Domain/EmailProvider/Models/EmailOpportunityAnnounced.cs b/src/api/src/domain/Yoma.Core.Domain/Notification/Models/NotificationOpportunityAnnounced.cs similarity index 83% rename from src/api/src/domain/Yoma.Core.Domain/EmailProvider/Models/EmailOpportunityAnnounced.cs rename to src/api/src/domain/Yoma.Core.Domain/Notification/Models/NotificationOpportunityAnnounced.cs index 77537ae34..74b9fcf72 100644 --- a/src/api/src/domain/Yoma.Core.Domain/EmailProvider/Models/EmailOpportunityAnnounced.cs +++ b/src/api/src/domain/Yoma.Core.Domain/Notification/Models/NotificationOpportunityAnnounced.cs @@ -1,14 +1,14 @@ using Newtonsoft.Json; -namespace Yoma.Core.Domain.EmailProvider.Models +namespace Yoma.Core.Domain.Notification.Models { - public class EmailOpportunityAnnounced : EmailBase + public class NotificationOpportunityAnnounced : NotificationBase { [JsonProperty("opportunities")] - public List Opportunities { get; set; } + public List Opportunities { get; set; } } - public class EmailOpportunityAnnouncedItem + public class NotificationOpportunityAnnouncedItem { [JsonIgnore] public Guid Id { get; set; } diff --git a/src/api/src/domain/Yoma.Core.Domain/EmailProvider/Models/EmailOpportunityExpiration.cs b/src/api/src/domain/Yoma.Core.Domain/Notification/Models/NotificationOpportunityExpiration.cs similarity index 74% rename from src/api/src/domain/Yoma.Core.Domain/EmailProvider/Models/EmailOpportunityExpiration.cs rename to src/api/src/domain/Yoma.Core.Domain/Notification/Models/NotificationOpportunityExpiration.cs index 105478dde..f518ebd8a 100644 --- a/src/api/src/domain/Yoma.Core.Domain/EmailProvider/Models/EmailOpportunityExpiration.cs +++ b/src/api/src/domain/Yoma.Core.Domain/Notification/Models/NotificationOpportunityExpiration.cs @@ -1,17 +1,17 @@ using Newtonsoft.Json; -namespace Yoma.Core.Domain.EmailProvider.Models +namespace Yoma.Core.Domain.Notification.Models { - public class EmailOpportunityExpiration : EmailBase + public class NotificationOpportunityExpiration : NotificationBase { [JsonProperty("withinNextDays")] public int? WithinNextDays { get; set; } [JsonProperty("opportunities")] - public List Opportunities { get; set; } + public List Opportunities { get; set; } } - public class EmailOpportunityExpirationItem + public class NotificationOpportunityExpirationItem { [JsonProperty("title")] public string Title { get; set; } diff --git a/src/api/src/domain/Yoma.Core.Domain/EmailProvider/Models/EmailOpportunityVerification.cs b/src/api/src/domain/Yoma.Core.Domain/Notification/Models/NotificationOpportunityVerification.cs similarity index 78% rename from src/api/src/domain/Yoma.Core.Domain/EmailProvider/Models/EmailOpportunityVerification.cs rename to src/api/src/domain/Yoma.Core.Domain/Notification/Models/NotificationOpportunityVerification.cs index 26ea32af7..03ef79d99 100644 --- a/src/api/src/domain/Yoma.Core.Domain/EmailProvider/Models/EmailOpportunityVerification.cs +++ b/src/api/src/domain/Yoma.Core.Domain/Notification/Models/NotificationOpportunityVerification.cs @@ -1,8 +1,8 @@ using Newtonsoft.Json; -namespace Yoma.Core.Domain.EmailProvider.Models +namespace Yoma.Core.Domain.Notification.Models { - public class EmailOpportunityVerification : EmailBase + public class NotificationOpportunityVerification : NotificationBase { [JsonProperty("yoIDURL")] public string? YoIDURL { get; set; } @@ -11,10 +11,17 @@ public class EmailOpportunityVerification : EmailBase public string? VerificationURL { get; set; } [JsonProperty("opportunities")] - public List Opportunities { get; set; } + public List Opportunities { get; set; } + + [JsonIgnore] + public override Dictionary ContentVariables => new() + { + { "1", SubjectSuffix }, + { "2", RecipientDisplayName } + }; } - public class EmailOpportunityVerificationItem + public class NotificationOpportunityVerificationItem { [JsonProperty("title")] public string Title { get; set; } diff --git a/src/api/src/domain/Yoma.Core.Domain/EmailProvider/Models/EmailOrganizationApproval.cs b/src/api/src/domain/Yoma.Core.Domain/Notification/Models/NotificationOrganizationApproval.cs similarity index 63% rename from src/api/src/domain/Yoma.Core.Domain/EmailProvider/Models/EmailOrganizationApproval.cs rename to src/api/src/domain/Yoma.Core.Domain/Notification/Models/NotificationOrganizationApproval.cs index 810d0bd05..dfba338f0 100644 --- a/src/api/src/domain/Yoma.Core.Domain/EmailProvider/Models/EmailOrganizationApproval.cs +++ b/src/api/src/domain/Yoma.Core.Domain/Notification/Models/NotificationOrganizationApproval.cs @@ -1,14 +1,14 @@ using Newtonsoft.Json; -namespace Yoma.Core.Domain.EmailProvider.Models +namespace Yoma.Core.Domain.Notification.Models { - public class EmailOrganizationApproval : EmailBase + public class NotificationOrganizationApproval : NotificationBase { [JsonProperty("organizations")] - public List Organizations { get; set; } + public List Organizations { get; set; } } - public class EmailOrganizationApprovalItem + public class NotificationOrganizationApprovalItem { [JsonProperty("name")] public string Name { get; set; } diff --git a/src/api/src/domain/Yoma.Core.Domain/Notification/Models/NotificationRecipient.cs b/src/api/src/domain/Yoma.Core.Domain/Notification/Models/NotificationRecipient.cs new file mode 100644 index 000000000..3198d5852 --- /dev/null +++ b/src/api/src/domain/Yoma.Core.Domain/Notification/Models/NotificationRecipient.cs @@ -0,0 +1,13 @@ +namespace Yoma.Core.Domain.Notification.Models +{ + public class NotificationRecipient + { + public string Username { get; set; } + + public string? PhoneNumber { get; set; } + + public string? Email { get; set; } + + public string? DisplayName { get; set; } + } +} diff --git a/src/api/src/domain/Yoma.Core.Domain/Notification/Services/NotificationDeliveryService.cs b/src/api/src/domain/Yoma.Core.Domain/Notification/Services/NotificationDeliveryService.cs new file mode 100644 index 000000000..5b5d10cbe --- /dev/null +++ b/src/api/src/domain/Yoma.Core.Domain/Notification/Services/NotificationDeliveryService.cs @@ -0,0 +1,93 @@ +using Yoma.Core.Domain.Notification.Interfaces; +using Yoma.Core.Domain.Notification.Models; + +namespace Yoma.Core.Domain.Notification.Services +{ + public class NotificationDeliveryService : INotificationDeliveryService + { + #region Class Variables + private readonly IEmailProviderClient _emailProviderClient; + private readonly IMessageProviderClient _messageProviderClient; + private readonly INotificationPreferenceFilterService _notificationPreferenceFilterService; + #endregion + + #region Constructor + public NotificationDeliveryService(IEmailProviderClientFactory emailProviderClientFactory, + IMessageProviderClientFactory messageProviderClientFactory, + INotificationPreferenceFilterService notificationPreferenceFilterService) + { + _emailProviderClient = emailProviderClientFactory.CreateClient(); + _messageProviderClient = messageProviderClientFactory.CreateClient(); + _notificationPreferenceFilterService = notificationPreferenceFilterService; + } + #endregion + + #region Public Members + public async Task Send(NotificationType type, List? recipients, T data) where T : NotificationBase + { + if (recipients == null || recipients.Count == 0) return; + + // apply preference filtering + recipients = _notificationPreferenceFilterService.FilterRecipients(type, recipients); + if (recipients == null || recipients.Count == 0) return; + + var emailRecipients = recipients.Where(r => !string.IsNullOrEmpty(r.Email)).ToList(); + var messageRecipients = recipients.Except(emailRecipients).ToList(); + + // email notifications + if (emailRecipients.Count > 0) + await _emailProviderClient.Send(type, emailRecipients, data); + + // message notifications + if (messageRecipients.Count > 0) + await _messageProviderClient.Send(MessageType.WhatsApp, type, messageRecipients, data); + } + + public async Task Send(NotificationType type, List<(List Recipients, T Data)>? recipientDataGroups) where T : NotificationBase + { + if (recipientDataGroups == null || recipientDataGroups.Count == 0) return; + + // apply preference filtering + recipientDataGroups = recipientDataGroups + .Select(group => + ( + Recipients: _notificationPreferenceFilterService.FilterRecipients(type, group.Recipients ?? []) ?? [], + group.Data + )) + .Where(group => group.Recipients.Count > 0) + .ToList(); + if (recipientDataGroups.Count == 0) return; + + var emailRecipientGroups = recipientDataGroups + .SelectMany(group => new[] + { + ( + Recipients: group.Recipients.Where(r => !string.IsNullOrEmpty(r.Email)).ToList(), + group.Data + ) + }) + .Where(group => group.Recipients.Count > 0) + .ToList(); + + var messageRecipientGroups = recipientDataGroups + .SelectMany(group => new[] + { + ( + Recipients: group.Recipients.Where(r => string.IsNullOrEmpty(r.Email)).ToList(), + group.Data + ) + }) + .Where(group => group.Recipients.Count > 0) + .ToList(); + + // email notifications + if (emailRecipientGroups.Count > 0) + await _emailProviderClient.Send(type, emailRecipientGroups); + + // message notifications + if (messageRecipientGroups.Count > 0) + await _messageProviderClient.Send(MessageType.SMS, type, messageRecipientGroups); + } + } + #endregion +} diff --git a/src/api/src/domain/Yoma.Core.Domain/Notification/Services/NotificationPreferenceFilterService.cs b/src/api/src/domain/Yoma.Core.Domain/Notification/Services/NotificationPreferenceFilterService.cs new file mode 100644 index 000000000..4a2d0d1dd --- /dev/null +++ b/src/api/src/domain/Yoma.Core.Domain/Notification/Services/NotificationPreferenceFilterService.cs @@ -0,0 +1,76 @@ +using Microsoft.Extensions.Logging; +using Yoma.Core.Domain.Notification.Interfaces; +using Yoma.Core.Domain.Notification.Models; +using Yoma.Core.Domain.Entity; +using Yoma.Core.Domain.Entity.Helpers; +using Yoma.Core.Domain.Entity.Interfaces; + +namespace Yoma.Core.Domain.Notification.Services +{ + public class NotificationPreferenceFilterService : INotificationPreferenceFilterService + { + #region Class Variables + private readonly ILogger _logger; + private readonly IUserService _userService; + #endregion + + #region Constructor + public NotificationPreferenceFilterService(IUserService userService, ILogger logger) + { + _userService = userService; + _logger = logger; + } + #endregion + + #region Public Members + public List? FilterRecipients(NotificationType type, List? recipients) + { + if (recipients == null || recipients.Count == 0) return recipients; + + // recipient filtering not applicable to the types below + switch (type) + { + case NotificationType.ActionLink_Verify_Distribution: + return recipients; + } + + var setting = type switch + { + // user + NotificationType.Opportunity_Verification_Rejected or NotificationType.Opportunity_Verification_Completed or NotificationType.Opportunity_Verification_Pending => Setting.User_Notification_Opportunity_Completion, + NotificationType.Opportunity_Published => Setting.User_Notification_Opportunity_Published, + // organization admin + NotificationType.Organization_Approval_Approved or NotificationType.Organization_Approval_Declined => Setting.Organization_Admin_Notification_Organization_Approval, + NotificationType.Opportunity_Expiration_Expired or NotificationType.Opportunity_Expiration_WithinNextDays => Setting.Organization_Admin_Notification_Opportunity_Expiration, + NotificationType.Opportunity_Verification_Pending_Admin => Setting.Organization_Admin_Notification_Opportunity_Completion, + NotificationType.ActionLink_Verify_Approval_Approved or NotificationType.ActionLink_Verify_Approval_Declined => Setting.Organization_Admin_Notification_ActionLink_Verify_Approval, + // admin + NotificationType.Organization_Approval_Requested => Setting.Admin_Notification_Organization_Approval, + NotificationType.Opportunity_Posted_Admin => Setting.Admin_Notification_Opportunity_Posted, + NotificationType.ActionLink_Verify_Approval_Requested => Setting.Admin_Notification_ActionLink_Verify_Approval, + _ => throw new ArgumentOutOfRangeException(nameof(type), $"Type of '{type}' not supported"), + }; + var result = new List(); + + foreach (var recipient in recipients) + { + try + { + var settingsInfo = _userService.GetSettingsInfoByUsername(recipient.Username); + var settingValue = SettingsHelper.GetValue(settingsInfo, setting.ToString()); + + if (settingValue == true) + result.Add(recipient); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to evaluate recipient username preference"); + result.Add(recipient); + } + } + + return result; + } + #endregion + } +} diff --git a/src/api/src/domain/Yoma.Core.Domain/EmailProvider/Services/EmailURLFactory.cs b/src/api/src/domain/Yoma.Core.Domain/Notification/Services/NotificationURLFactory.cs similarity index 60% rename from src/api/src/domain/Yoma.Core.Domain/EmailProvider/Services/EmailURLFactory.cs rename to src/api/src/domain/Yoma.Core.Domain/Notification/Services/NotificationURLFactory.cs index ebb7d92b0..ff09a8683 100644 --- a/src/api/src/domain/Yoma.Core.Domain/EmailProvider/Services/EmailURLFactory.cs +++ b/src/api/src/domain/Yoma.Core.Domain/Notification/Services/NotificationURLFactory.cs @@ -2,25 +2,25 @@ using Microsoft.Extensions.Options; using Yoma.Core.Domain.ActionLink; using Yoma.Core.Domain.Core.Models; -using Yoma.Core.Domain.EmailProvider.Interfaces; +using Yoma.Core.Domain.Notification.Interfaces; -namespace Yoma.Core.Domain.EmailProvider.Services +namespace Yoma.Core.Domain.Notification.Services { - public class EmailURLFactory : IEmailURLFactory + public class NotificationURLFactory : INotificationURLFactory { #region Class Variables private readonly AppSettings _appSettings; #endregion #region Constructor - public EmailURLFactory(IOptions appSettings) + public NotificationURLFactory(IOptions appSettings) { _appSettings = appSettings.Value; } #endregion #region Public Members - public string ActionLinkVerifyApprovalItemUrl(EmailType emailType, Guid? organizationId) + public string ActionLinkVerifyApprovalItemUrl(NotificationType emailType, Guid? organizationId) { if (organizationId == Guid.Empty) throw new ArgumentNullException(nameof(organizationId)); @@ -29,19 +29,19 @@ public string ActionLinkVerifyApprovalItemUrl(EmailType emailType, Guid? organiz LinkStatus? status; switch (emailType) { - case EmailType.ActionLink_Verify_Approval_Requested: + case NotificationType.ActionLink_Verify_Approval_Requested: result = result.AppendPathSegment("admin").AppendPathSegment("links").ToString(); status = LinkStatus.Inactive; break; - case EmailType.ActionLink_Verify_Approval_Approved: - case EmailType.ActionLink_Verify_Approval_Declined: + case NotificationType.ActionLink_Verify_Approval_Approved: + case NotificationType.ActionLink_Verify_Approval_Declined: if (!organizationId.HasValue) throw new InvalidOperationException("Organization id expected"); result = result.AppendPathSegment("organisations").AppendPathSegment(organizationId).AppendPathSegment("links").ToString(); - status = emailType == EmailType.ActionLink_Verify_Approval_Approved ? LinkStatus.Active : LinkStatus.Declined; + status = emailType == NotificationType.ActionLink_Verify_Approval_Approved ? LinkStatus.Active : LinkStatus.Declined; break; default: @@ -52,7 +52,7 @@ public string ActionLinkVerifyApprovalItemUrl(EmailType emailType, Guid? organiz return result; } - public string OrganizationApprovalItemURL(EmailType emailType, Guid organizationId) + public string OrganizationApprovalItemURL(NotificationType emailType, Guid organizationId) { if (organizationId == Guid.Empty) throw new ArgumentNullException(nameof(organizationId)); @@ -60,15 +60,15 @@ public string OrganizationApprovalItemURL(EmailType emailType, Guid organization var result = _appSettings.AppBaseURL.AppendPathSegment("organisations").AppendPathSegment(organizationId).ToString(); result = emailType switch { - EmailType.Organization_Approval_Requested => result.AppendPathSegment("verify").ToString(), - EmailType.Organization_Approval_Approved => result.AppendPathSegment("opportunities").ToString(), - EmailType.Organization_Approval_Declined => result.AppendPathSegment("edit").ToString(), + NotificationType.Organization_Approval_Requested => result.AppendPathSegment("verify").ToString(), + NotificationType.Organization_Approval_Approved => result.AppendPathSegment("opportunities").ToString(), + NotificationType.Organization_Approval_Declined => result.AppendPathSegment("edit").ToString(), _ => throw new ArgumentOutOfRangeException(nameof(emailType), $"Type of '{emailType}' not supported"), }; return result; } - public string OpportunityVerificationItemURL(EmailType emailType, Guid opportunityId, Guid? organizationId) + public string OpportunityVerificationItemURL(NotificationType emailType, Guid opportunityId, Guid? organizationId) { if (opportunityId == Guid.Empty) throw new ArgumentNullException(nameof(opportunityId)); @@ -76,11 +76,11 @@ public string OpportunityVerificationItemURL(EmailType emailType, Guid opportuni var result = _appSettings.AppBaseURL.AppendPathSegment("opportunities").AppendPathSegment(opportunityId).ToString(); switch (emailType) { - case EmailType.Opportunity_Verification_Rejected: - case EmailType.Opportunity_Verification_Completed: - case EmailType.Opportunity_Verification_Pending: + case NotificationType.Opportunity_Verification_Rejected: + case NotificationType.Opportunity_Verification_Completed: + case NotificationType.Opportunity_Verification_Pending: break; - case EmailType.Opportunity_Verification_Pending_Admin: + case NotificationType.Opportunity_Verification_Pending_Admin: if (!organizationId.HasValue || organizationId.Value == Guid.Empty) throw new ArgumentNullException(nameof(organizationId)); @@ -95,25 +95,25 @@ public string OpportunityVerificationItemURL(EmailType emailType, Guid opportuni return result; } - public string? OpportunityVerificationYoIDURL(EmailType emailType) + public string? OpportunityVerificationYoIDURL(NotificationType emailType) { var result = _appSettings.AppBaseURL.AppendPathSegment("yoid/opportunities").ToString(); switch (emailType) { - case EmailType.Opportunity_Verification_Rejected: + case NotificationType.Opportunity_Verification_Rejected: result = result.AppendPathSegment("rejected").ToString(); break; - case EmailType.Opportunity_Verification_Completed: - case EmailType.ActionLink_Verify_Distribution: + case NotificationType.Opportunity_Verification_Completed: + case NotificationType.ActionLink_Verify_Distribution: result = result.AppendPathSegment("completed").ToString(); break; - case EmailType.Opportunity_Verification_Pending: + case NotificationType.Opportunity_Verification_Pending: result = result.AppendPathSegment("pending").ToString(); break; - case EmailType.Opportunity_Verification_Pending_Admin: + case NotificationType.Opportunity_Verification_Pending_Admin: return null; default: @@ -123,20 +123,20 @@ public string OpportunityVerificationItemURL(EmailType emailType, Guid opportuni return result; } - public string? OpportunityVerificationURL(EmailType emailType, Guid organizationId) + public string? OpportunityVerificationURL(NotificationType emailType, Guid organizationId) { if (organizationId == Guid.Empty) throw new ArgumentNullException(nameof(organizationId)); return emailType switch { - EmailType.Opportunity_Verification_Rejected or EmailType.Opportunity_Verification_Completed or EmailType.Opportunity_Verification_Pending => null, - EmailType.Opportunity_Verification_Pending_Admin => _appSettings.AppBaseURL.AppendPathSegment("organisations").AppendPathSegment(organizationId).AppendPathSegment("verifications").ToString(), + NotificationType.Opportunity_Verification_Rejected or NotificationType.Opportunity_Verification_Completed or NotificationType.Opportunity_Verification_Pending => null, + NotificationType.Opportunity_Verification_Pending_Admin => _appSettings.AppBaseURL.AppendPathSegment("organisations").AppendPathSegment(organizationId).AppendPathSegment("verifications").ToString(), _ => throw new ArgumentOutOfRangeException(nameof(emailType), $"Type of '{emailType}' not supported"), }; } - public string OpportunityExpirationItemURL(EmailType emailType, Guid opportunityId, Guid organizationId) + public string OpportunityExpirationItemURL(NotificationType emailType, Guid opportunityId, Guid organizationId) { if (opportunityId == Guid.Empty) throw new ArgumentNullException(nameof(opportunityId)); @@ -146,14 +146,14 @@ public string OpportunityExpirationItemURL(EmailType emailType, Guid opportunity return emailType switch { - EmailType.Opportunity_Expiration_Expired or EmailType.Opportunity_Expiration_WithinNextDays + NotificationType.Opportunity_Expiration_Expired or NotificationType.Opportunity_Expiration_WithinNextDays => _appSettings.AppBaseURL.AppendPathSegment("organisations").AppendPathSegment(organizationId).AppendPathSegment("opportunities") .AppendPathSegment(opportunityId).AppendPathSegment("info").ToString(), _ => throw new ArgumentOutOfRangeException(nameof(emailType), $"Type of '{emailType}' not supported"), }; } - public string OpportunityAnnouncedItemURL(EmailType emailType, Guid opportunityId, Guid organizationId) + public string OpportunityAnnouncedItemURL(NotificationType emailType, Guid opportunityId, Guid organizationId) { if (opportunityId == Guid.Empty) throw new ArgumentNullException(nameof(opportunityId)); @@ -163,8 +163,8 @@ public string OpportunityAnnouncedItemURL(EmailType emailType, Guid opportunityI return emailType switch { - EmailType.Opportunity_Published => _appSettings.AppBaseURL.AppendPathSegment("opportunities").AppendPathSegment(opportunityId).ToString(), - EmailType.Opportunity_Posted_Admin => _appSettings.AppBaseURL.AppendPathSegment("organisations").AppendPathSegment(organizationId) + NotificationType.Opportunity_Published => _appSettings.AppBaseURL.AppendPathSegment("opportunities").AppendPathSegment(opportunityId).ToString(), + NotificationType.Opportunity_Posted_Admin => _appSettings.AppBaseURL.AppendPathSegment("organisations").AppendPathSegment(organizationId) .AppendPathSegment("opportunities").AppendPathSegment(opportunityId).AppendPathSegment("info") .SetQueryParam("returnUrl", "/admin/opportunities").ToString(), _ => throw new ArgumentOutOfRangeException(nameof(emailType), $"Type of '{emailType}' not supported"), diff --git a/src/api/src/domain/Yoma.Core.Domain/Opportunity/Services/OpportunityBackgroundService.cs b/src/api/src/domain/Yoma.Core.Domain/Opportunity/Services/OpportunityBackgroundService.cs index 78541d47b..323b5be7d 100644 --- a/src/api/src/domain/Yoma.Core.Domain/Opportunity/Services/OpportunityBackgroundService.cs +++ b/src/api/src/domain/Yoma.Core.Domain/Opportunity/Services/OpportunityBackgroundService.cs @@ -7,9 +7,9 @@ using Yoma.Core.Domain.Core.Helpers; using Yoma.Core.Domain.Core.Interfaces; using Yoma.Core.Domain.Core.Models; -using Yoma.Core.Domain.EmailProvider; -using Yoma.Core.Domain.EmailProvider.Interfaces; -using Yoma.Core.Domain.EmailProvider.Models; +using Yoma.Core.Domain.Notification; +using Yoma.Core.Domain.Notification.Interfaces; +using Yoma.Core.Domain.Notification.Models; using Yoma.Core.Domain.Entity; using Yoma.Core.Domain.Entity.Interfaces; using Yoma.Core.Domain.Entity.Interfaces.Lookups; @@ -29,11 +29,10 @@ public class OpportunityBackgroundService : IOpportunityBackgroundService private readonly IOpportunityStatusService _opportunityStatusService; private readonly IOrganizationService _organizationService; private readonly IOrganizationStatusService _organizationStatusService; - private readonly IEmailProviderClient _emailProviderClient; + private readonly INotificationDeliveryService _notificationDeliveryService; private readonly IUserService _userService; private readonly ICountryService _countryService; - private readonly IEmailURLFactory _emailURLFactory; - private readonly IEmailPreferenceFilterService _emailPreferenceFilterService; + private readonly INotificationURLFactory _notificationURLFactory; private readonly IRepositoryBatchedValueContainsWithNavigation _opportunityRepository; private readonly IDistributedLockService _distributedLockService; private readonly IMediator _mediator; @@ -47,11 +46,10 @@ public OpportunityBackgroundService(ILogger logger IOpportunityStatusService opportunityStatusService, IOrganizationService organizationService, IOrganizationStatusService organizationStatusService, - IEmailProviderClientFactory emailProviderClientFactory, + INotificationDeliveryService notificationDeliveryService, IUserService userService, ICountryService countryService, - IEmailURLFactory emailURLFactory, - IEmailPreferenceFilterService emailPreferenceFilterService, + INotificationURLFactory notificationURLFactory, IRepositoryBatchedValueContainsWithNavigation opportunityRepository, IDistributedLockService distributedLockService, IMediator mediator) @@ -61,11 +59,10 @@ public OpportunityBackgroundService(ILogger logger _opportunityStatusService = opportunityStatusService; _organizationService = organizationService; _organizationStatusService = organizationStatusService; - _emailProviderClient = emailProviderClientFactory.CreateClient(); + _notificationDeliveryService = notificationDeliveryService; _userService = userService; _countryService = countryService; - _emailURLFactory = emailURLFactory; - _emailPreferenceFilterService = emailPreferenceFilterService; + _notificationURLFactory = notificationURLFactory; _opportunityRepository = opportunityRepository; _distributedLockService = distributedLockService; _mediator = mediator; @@ -106,7 +103,7 @@ public async Task ProcessPublishedNotifications() .OrderBy(o => o.DateStart).ThenBy(o => o.Title).ThenBy(o => o.Id).ToList(); if (items.Count == 0) return; - await SendEmailPublished(items, executeUntil); + await SendNotificationPublished(items, executeUntil); _logger.LogInformation("Processed opportunity published notifications"); } @@ -156,7 +153,7 @@ public async Task ProcessExpiration() o.DateEnd.HasValue && o.DateEnd.Value <= DateTimeOffset.UtcNow).OrderBy(o => o.DateEnd).Take(_scheduleJobOptions.OpportunityExpirationBatchSize).ToList(); if (items.Count == 0) break; - var user = _userService.GetByEmail(HttpContextAccessorHelper.GetUsernameSystem, false, false); + var user = _userService.GetByUsername(HttpContextAccessorHelper.GetUsernameSystem, false, false); foreach (var item in items) { @@ -168,7 +165,7 @@ public async Task ProcessExpiration() items = await _opportunityRepository.Update(items); - await SendEmailExpiration(items, EmailType.Opportunity_Expiration_Expired); + await SendNotificationExpiration(items, NotificationType.Opportunity_Expiration_Expired); foreach (var item in items) await _mediator.Publish(new OpportunityEvent(Core.EventType.Update, item)); @@ -222,7 +219,7 @@ public async Task ProcessExpirationNotifications() .OrderBy(o => o.DateEnd).Take(_scheduleJobOptions.OpportunityExpirationBatchSize).ToList(); if (items.Count == 0) return; - await SendEmailExpiration(items, EmailType.Opportunity_Expiration_WithinNextDays); + await SendNotificationExpiration(items, NotificationType.Opportunity_Expiration_WithinNextDays); _logger.LogInformation("Processed opportunity expiration notifications"); } @@ -274,7 +271,7 @@ public async Task ProcessDeletion() .OrderBy(o => o.DateModified).Take(_scheduleJobOptions.OpportunityDeletionBatchSize).ToList(); if (items.Count == 0) break; - var user = _userService.GetByEmail(HttpContextAccessorHelper.GetUsernameSystem, false, false); + var user = _userService.GetByUsername(HttpContextAccessorHelper.GetUsernameSystem, false, false); foreach (var item in items) { @@ -312,9 +309,9 @@ public async Task ProcessDeletion() #endregion #region Private Members - private async Task SendEmailPublished(List items, DateTimeOffset executeUntil) + private async Task SendNotificationPublished(List items, DateTimeOffset executeUntil) { - var emailType = EmailType.Opportunity_Published; + var notificationType = NotificationType.Opportunity_Published; var countryWorldWideId = _countryService.GetByCodeAplha2(Core.Country.Worldwide.ToDescription()).Id; try @@ -324,10 +321,10 @@ private async Task SendEmailPublished(List items, DateTimeOf int pageSize = 1000; while (executeUntil > DateTimeOffset.UtcNow) { - var recipientDataGroups = new List<(List Recipients, EmailOpportunityAnnounced Data)>(); + var recipientDataGroups = new List<(List Recipients, NotificationOpportunityAnnounced Data)>(); var searchResult = _userService.Search( - new UserSearchFilter // implicitly includes only users with a confirmed email + new UserSearchFilter // implicitly includes only users with a confirmed notification { YoIDOnboarded = true, PageNumber = pageNumber, @@ -358,15 +355,15 @@ private async Task SendEmailPublished(List items, DateTimeOf .ToList(); if (countryOpportunities.Count == 0) continue; - var data = new EmailOpportunityAnnounced + var data = new NotificationOpportunityAnnounced { - Opportunities = countryOpportunities.Select(item => new EmailOpportunityAnnouncedItem + Opportunities = countryOpportunities.Select(item => new NotificationOpportunityAnnouncedItem { Id = item.Id, Title = item.Title, DateStart = item.DateStart, DateEnd = item.DateEnd, - URL = _emailURLFactory.OpportunityAnnouncedItemURL(emailType, item.Id, item.OrganizationId), + URL = _notificationURLFactory.OpportunityAnnouncedItemURL(notificationType, item.Id, item.OrganizationId), ZltoReward = item.ZltoReward, YomaReward = item.YomaReward }).ToList() @@ -376,11 +373,13 @@ private async Task SendEmailPublished(List items, DateTimeOf var existingGroup = recipientDataGroups.FirstOrDefault(group => new HashSet(group.Data.Opportunities.Select(o => o.Id)).SetEquals(new HashSet(countryOpportunities.Select(o => o.Id)))); - if (existingGroup.Equals(default((List Recipients, EmailOpportunityAnnounced Data)))) + if (existingGroup.Equals(default((List Recipients, NotificationOpportunityAnnounced Data)))) { // create a new recipient list - var recipients = userGroup.Value.Select(u => new EmailRecipient + var recipients = userGroup.Value.Select(u => new NotificationRecipient { + Username = u.Username, + PhoneNumber = u.PhoneNumber, Email = u.Email, DisplayName = u.DisplayName }).ToList(); @@ -390,23 +389,19 @@ private async Task SendEmailPublished(List items, DateTimeOf else { // add recipients to the existing group - existingGroup.Recipients.AddRange(userGroup.Value.Select(u => new EmailRecipient + existingGroup.Recipients.AddRange(userGroup.Value.Select(u => new NotificationRecipient { + Username = u.Username, + PhoneNumber = u.PhoneNumber, Email = u.Email, DisplayName = u.DisplayName }).ToList()); } } - // filter recipients by email preferences before proceeding to the next page - recipientDataGroups = recipientDataGroups - .Select(group => (Recipients: _emailPreferenceFilterService.FilterRecipients(emailType, group.Recipients) ?? [], group.Data)) - .Where(group => group.Recipients.Count > 0) - .ToList(); - - // send emails in one go for the paged results + // send notifications in one go for the paged results if (recipientDataGroups.Count > 0) - await _emailProviderClient.Send(emailType, recipientDataGroups); + await _notificationDeliveryService.Send(notificationType, recipientDataGroups); pageNumber++; @@ -415,29 +410,26 @@ private async Task SendEmailPublished(List items, DateTimeOf } catch (Exception ex) { - _logger.LogError(ex, "Failed to send email"); + _logger.LogError(ex, "Failed to send notification"); } } - private async Task SendEmailExpiration(List items, EmailType type) + private async Task SendNotificationExpiration(List items, NotificationType type) { var groupedOpportunities = items - .SelectMany(op => _organizationService.ListAdmins(op.OrganizationId, false, false), (op, admin) => new { Administrator = admin, Opportunity = op }) - .GroupBy(item => item.Administrator, item => item.Opportunity); + .SelectMany(op => _organizationService.ListAdmins(op.OrganizationId, false, false), (op, admin) => new { Administrator = admin, Opportunity = op }) + .GroupBy(item => item.Administrator, item => item.Opportunity); foreach (var group in groupedOpportunities) { try { - var recipients = new List + var recipients = new List { - new() { Email = group.Key.Email, DisplayName = group.Key.DisplayName } + new() { Username = group.Key.Username, PhoneNumber = group.Key.PhoneNumber, Email = group.Key.Email, DisplayName = group.Key.DisplayName } }; - recipients = _emailPreferenceFilterService.FilterRecipients(type, recipients); - if (recipients == null || recipients.Count == 0) continue; - - var data = new EmailOpportunityExpiration + var data = new NotificationOpportunityExpiration { WithinNextDays = _scheduleJobOptions.OpportunityExpirationNotificationIntervalInDays, Opportunities = [] @@ -445,22 +437,22 @@ private async Task SendEmailExpiration(List items, EmailType foreach (var op in group) { - data.Opportunities.Add(new EmailOpportunityExpirationItem + data.Opportunities.Add(new NotificationOpportunityExpirationItem { Title = op.Title, DateStart = op.DateStart, DateEnd = op.DateEnd, - URL = _emailURLFactory.OpportunityExpirationItemURL(type, op.Id, op.OrganizationId) + URL = _notificationURLFactory.OpportunityExpirationItemURL(type, op.Id, op.OrganizationId) }); } - await _emailProviderClient.Send(type, recipients, data); + await _notificationDeliveryService.Send(type, recipients, data); - _logger.LogInformation("Successfully send email"); + _logger.LogInformation("Successfully send notification"); } catch (Exception ex) { - _logger.LogError(ex, "Failed to send email"); + _logger.LogError(ex, "Failed to send notification"); } } } diff --git a/src/api/src/domain/Yoma.Core.Domain/Opportunity/Services/OpportunityService.cs b/src/api/src/domain/Yoma.Core.Domain/Opportunity/Services/OpportunityService.cs index 159c11aca..47922cc9b 100644 --- a/src/api/src/domain/Yoma.Core.Domain/Opportunity/Services/OpportunityService.cs +++ b/src/api/src/domain/Yoma.Core.Domain/Opportunity/Services/OpportunityService.cs @@ -11,9 +11,9 @@ using Yoma.Core.Domain.Core.Helpers; using Yoma.Core.Domain.Core.Interfaces; using Yoma.Core.Domain.Core.Models; -using Yoma.Core.Domain.EmailProvider; -using Yoma.Core.Domain.EmailProvider.Interfaces; -using Yoma.Core.Domain.EmailProvider.Models; +using Yoma.Core.Domain.Notification; +using Yoma.Core.Domain.Notification.Interfaces; +using Yoma.Core.Domain.Notification.Models; using Yoma.Core.Domain.Entity; using Yoma.Core.Domain.Entity.Interfaces; using Yoma.Core.Domain.Entity.Interfaces.Lookups; @@ -53,9 +53,8 @@ public class OpportunityService : IOpportunityService private readonly ITimeIntervalService _timeIntervalService; private readonly IBlobService _blobService; private readonly IUserService _userService; - private readonly IEmailURLFactory _emailURLFactory; - private readonly IEmailPreferenceFilterService _emailPreferenceFilterService; - private readonly IEmailProviderClient _emailProviderClient; + private readonly INotificationURLFactory _notificationURLFactory; + private readonly INotificationDeliveryService _notificationDeliveryService; private readonly IIdentityProviderClient _identityProviderClient; private readonly ISharingInfoService _sharingInfoService; @@ -101,9 +100,8 @@ public OpportunityService(ILogger logger, ITimeIntervalService timeIntervalService, IBlobService blobService, IUserService userService, - IEmailURLFactory emailURLFactory, - IEmailPreferenceFilterService emailPreferenceFilterService, - IEmailProviderClientFactory emailProviderClientFactory, + INotificationURLFactory notificationURLFactory, + INotificationDeliveryService notificationDeliveryService, IIdentityProviderClientFactory identityProviderClientFactory, ISharingInfoService sharingInfoService, OpportunityRequestValidatorCreate opportunityRequestValidatorCreate, @@ -137,9 +135,8 @@ public OpportunityService(ILogger logger, _timeIntervalService = timeIntervalService; _blobService = blobService; _userService = userService; - _emailURLFactory = emailURLFactory; - _emailPreferenceFilterService = emailPreferenceFilterService; - _emailProviderClient = emailProviderClientFactory.CreateClient(); + _notificationURLFactory = notificationURLFactory; + _notificationDeliveryService = notificationDeliveryService; _identityProviderClient = identityProviderClientFactory.CreateClient(); _sharingInfoService = sharingInfoService; @@ -427,7 +424,7 @@ public OpportunitySearchResultsCriteria SearchCriteriaOpportunities(OpportunityS Guid? userCountryId = null; if (HttpContextAccessorHelper.UserContextAvailable(_httpContextAccessor)) { - var user = _userService.GetByEmail(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, false), false, false); + var user = _userService.GetByUsername(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, false), false, false); userCountryId = user.CountryId; } @@ -1016,7 +1013,7 @@ public OpportunitySearchResults Search(OpportunitySearchFilterAdmin filter, bool status = Status.Expired; } - var user = _userService.GetByEmail(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, !ensureOrganizationAuthorization), false, false); + var user = _userService.GetByUsername(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, !ensureOrganizationAuthorization), false, false); var organization = _organizationService.GetById(request.OrganizationId, false, true, false); @@ -1113,7 +1110,7 @@ await _executionStrategyService.ExecuteInExecutionStrategyAsync(async () => result.SetPublished(); //sent when activated irrespective of organization status (sent to admin) - if (result.Status == Status.Active) await SendEmail(result, EmailType.Opportunity_Posted_Admin); + if (result.Status == Status.Active) await SendNotification(result, NotificationType.Opportunity_Posted_Admin); await _mediator.Publish(new OpportunityEvent(EventType.Create, result)); @@ -1146,7 +1143,7 @@ await _executionStrategyService.ExecuteInExecutionStrategyAsync(async () => if (existingByTitle != null && result.Id != existingByTitle.Id) throw new ValidationException($"{nameof(Models.Opportunity)} with the specified name '{request.Title}' already exists"); - var user = _userService.GetByEmail(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, !ensureOrganizationAuthorization), false, false); + var user = _userService.GetByUsername(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, !ensureOrganizationAuthorization), false, false); var organization = _organizationService.GetById(request.OrganizationId, false, true, false); @@ -1286,7 +1283,7 @@ public async Task AllocateRewards(Guid id, bo throw new ValidationException($"The number of participants cannot exceed the limit. The current count is '{opportunity.ParticipantCount ?? 0}', and the limit is '{opportunity.ParticipantLimit.Value}'. Please edit the opportunity to increase or remove the limit, or reject the verification request"); var organization = _organizationService.GetById(opportunity.OrganizationId, false, false, false); - var user = _userService.GetByEmail(HttpContextAccessorHelper.GetUsernameSystem, false, false); + var user = _userService.GetByUsername(HttpContextAccessorHelper.GetUsernameSystem, false, false); var result = new OpportunityAllocateRewardResponse { @@ -1344,7 +1341,7 @@ await _executionStrategyService.ExecuteInExecutionStrategyAsync(async () => { var result = GetById(id, true, true, false); - var user = _userService.GetByEmail(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, false), false, false); + var user = _userService.GetByUsername(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, false), false, false); AssertUpdatable(result); @@ -1362,7 +1359,7 @@ await _executionStrategyService.ExecuteInExecutionStrategyAsync(async () => { var result = GetById(id, true, true, ensureOrganizationAuthorization); - var user = _userService.GetByEmail(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, !ensureOrganizationAuthorization), false, false); + var user = _userService.GetByUsername(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, !ensureOrganizationAuthorization), false, false); EventType? eventType = null; switch (status) @@ -1410,7 +1407,7 @@ await _executionStrategyService.ExecuteInExecutionStrategyAsync(async () => result.SetPublished(); //sent when activated irrespective of organization status (sent to admin) - if (status == Status.Active) await SendEmail(result, EmailType.Opportunity_Posted_Admin); + if (status == Status.Active) await SendNotification(result, NotificationType.Opportunity_Posted_Admin); await _mediator.Publish(new OpportunityEvent(eventType.Value, result)); @@ -1423,7 +1420,7 @@ await _executionStrategyService.ExecuteInExecutionStrategyAsync(async () => AssertUpdatable(result); - var user = _userService.GetByEmail(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, !ensureOrganizationAuthorization), false, false); + var user = _userService.GetByUsername(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, !ensureOrganizationAuthorization), false, false); await _executionStrategyService.ExecuteInExecutionStrategyAsync(async () => { @@ -1448,7 +1445,7 @@ await _executionStrategyService.ExecuteInExecutionStrategyAsync(async () => AssertUpdatable(result); - var user = _userService.GetByEmail(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, !ensureOrganizationAuthorization), false, false); + var user = _userService.GetByUsername(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, !ensureOrganizationAuthorization), false, false); await _executionStrategyService.ExecuteInExecutionStrategyAsync(async () => { @@ -1470,7 +1467,7 @@ await _executionStrategyService.ExecuteInExecutionStrategyAsync(async () => AssertUpdatable(result); - var user = _userService.GetByEmail(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, !ensureOrganizationAuthorization), false, false); + var user = _userService.GetByUsername(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, !ensureOrganizationAuthorization), false, false); await _executionStrategyService.ExecuteInExecutionStrategyAsync(async () => { @@ -1508,7 +1505,7 @@ await _executionStrategyService.ExecuteInExecutionStrategyAsync(async () => AssertUpdatable(result); - var user = _userService.GetByEmail(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, !ensureOrganizationAuthorization), false, false); + var user = _userService.GetByUsername(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, !ensureOrganizationAuthorization), false, false); await _executionStrategyService.ExecuteInExecutionStrategyAsync(async () => { @@ -1533,7 +1530,7 @@ await _executionStrategyService.ExecuteInExecutionStrategyAsync(async () => AssertUpdatable(result); - var user = _userService.GetByEmail(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, !ensureOrganizationAuthorization), false, false); + var user = _userService.GetByUsername(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, !ensureOrganizationAuthorization), false, false); await _executionStrategyService.ExecuteInExecutionStrategyAsync(async () => { @@ -1558,7 +1555,7 @@ await _executionStrategyService.ExecuteInExecutionStrategyAsync(async () => AssertUpdatable(result); - var user = _userService.GetByEmail(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, !ensureOrganizationAuthorization), false, false); + var user = _userService.GetByUsername(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, !ensureOrganizationAuthorization), false, false); await _executionStrategyService.ExecuteInExecutionStrategyAsync(async () => { @@ -1583,7 +1580,7 @@ await _executionStrategyService.ExecuteInExecutionStrategyAsync(async () => AssertUpdatable(result); - var user = _userService.GetByEmail(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, !ensureOrganizationAuthorization), false, false); + var user = _userService.GetByUsername(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, !ensureOrganizationAuthorization), false, false); await _executionStrategyService.ExecuteInExecutionStrategyAsync(async () => { @@ -1608,7 +1605,7 @@ await _executionStrategyService.ExecuteInExecutionStrategyAsync(async () => AssertUpdatable(result); - var user = _userService.GetByEmail(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, !ensureOrganizationAuthorization), false, false); + var user = _userService.GetByUsername(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, !ensureOrganizationAuthorization), false, false); await _executionStrategyService.ExecuteInExecutionStrategyAsync(async () => { @@ -1636,7 +1633,7 @@ await _executionStrategyService.ExecuteInExecutionStrategyAsync(async () => if (result.VerificationEnabled && (result.VerificationTypes == null || result.VerificationTypes.All(o => verificationTypes.Contains(o.Type)))) throw new ValidationException("One or more verification types are required when verification is supported. Removal will result in no associated verification types"); - var user = _userService.GetByEmail(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, !ensureOrganizationAuthorization), false, false); + var user = _userService.GetByUsername(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, !ensureOrganizationAuthorization), false, false); await _executionStrategyService.ExecuteInExecutionStrategyAsync(async () => { @@ -1707,16 +1704,16 @@ private async Task AssertUpdatablePartnerSharing(OpportunityRequestUpdate reques throw new ValidationException($"The {nameof(Models.Opportunity)} has already been shared and cannot be updated for the following reasons: {reasonText}"); } - private async Task SendEmail(Models.Opportunity opportunity, EmailType type) + private async Task SendNotification(Models.Opportunity opportunity, NotificationType type) { try { - List? recipients = null; + List? recipients = null; switch (type) { - case EmailType.Opportunity_Posted_Admin: + case NotificationType.Opportunity_Posted_Admin: var superAdmins = await _identityProviderClient.ListByRole(Constants.Role_Admin); - recipients = superAdmins?.Select(o => new EmailRecipient { Email = o.Email, DisplayName = o.ToDisplayName() }).ToList(); + recipients = superAdmins?.Select(o => new NotificationRecipient { Username = o.Username, PhoneNumber = o.PhoneNumber, Email = o.Email, DisplayName = o.ToDisplayName() ?? o.Username }).ToList(); break; @@ -1724,29 +1721,26 @@ private async Task SendEmail(Models.Opportunity opportunity, EmailType type) throw new ArgumentOutOfRangeException(nameof(type), $"Type of '{type}' not supported"); } - recipients = _emailPreferenceFilterService.FilterRecipients(type, recipients); - if (recipients == null || recipients.Count == 0) return; - - var data = new EmailOpportunityAnnounced + var data = new NotificationOpportunityAnnounced { Opportunities = [new() { Title = opportunity.Title, DateStart = opportunity.DateStart, DateEnd = opportunity.DateEnd, - URL = _emailURLFactory.OpportunityAnnouncedItemURL(type, opportunity.Id, opportunity.OrganizationId), + URL = _notificationURLFactory.OpportunityAnnouncedItemURL(type, opportunity.Id, opportunity.OrganizationId), ZltoReward = opportunity.ZltoReward, YomaReward = opportunity.YomaReward }] }; - await _emailProviderClient.Send(type, recipients, data); + await _notificationDeliveryService.Send(type, recipients, data); - _logger.LogInformation("Successfully send email"); + _logger.LogInformation("Successfully send notification"); } catch (Exception ex) { - _logger.LogError(ex, "Failed to send email"); + _logger.LogError(ex, "Failed to send notification"); } } diff --git a/src/api/src/domain/Yoma.Core.Domain/Reward/Enumerations.cs b/src/api/src/domain/Yoma.Core.Domain/Reward/Enumerations.cs index 1cdac8251..1391ebda3 100644 --- a/src/api/src/domain/Yoma.Core.Domain/Reward/Enumerations.cs +++ b/src/api/src/domain/Yoma.Core.Domain/Reward/Enumerations.cs @@ -4,6 +4,7 @@ public enum WalletCreationStatus { Unscheduled, Pending, + PendingUsernameUpdate, Created, Error } diff --git a/src/api/src/domain/Yoma.Core.Domain/Reward/Interfaces/IWalletService.cs b/src/api/src/domain/Yoma.Core.Domain/Reward/Interfaces/IWalletService.cs index a7c7350ef..58bcdb418 100644 --- a/src/api/src/domain/Yoma.Core.Domain/Reward/Interfaces/IWalletService.cs +++ b/src/api/src/domain/Yoma.Core.Domain/Reward/Interfaces/IWalletService.cs @@ -4,20 +4,22 @@ namespace Yoma.Core.Domain.Reward.Interfaces { public interface IWalletService { - (string userEmail, string walletId) GetWalletId(Guid userId); + (string username, string walletId) GetWalletId(Guid userId); - (string userEmail, string? walletId) GetWalletIdOrNull(Guid userId); + (string? username, string? walletId) GetWalletIdOrNull(Guid userId); Task<(WalletCreationStatus status, WalletBalance balance)> GetWalletStatusAndBalance(Guid userId); Task SearchVouchers(WalletVoucherSearchFilter filter); - Task CreateWallet(Guid userId); + Task<(string username, Wallet wallet)> CreateWallet(Guid userId); Task CreateWalletOrScheduleCreation(Guid? userId); List ListPendingCreationSchedule(int batchSize, List idsToSkip); - Task UpdateScheduleCreation(WalletCreation item); + Task UpdateScheduleCreation(WalletCreation item, WalletCreationStatus retryStatusOnFailure); + + Task UpdateWalletUsername(Guid userId); } } diff --git a/src/api/src/domain/Yoma.Core.Domain/Reward/Interfaces/Provider/IRewardProviderClient.cs b/src/api/src/domain/Yoma.Core.Domain/Reward/Interfaces/Provider/IRewardProviderClient.cs index 5b6b17533..fdafcc7cb 100644 --- a/src/api/src/domain/Yoma.Core.Domain/Reward/Interfaces/Provider/IRewardProviderClient.cs +++ b/src/api/src/domain/Yoma.Core.Domain/Reward/Interfaces/Provider/IRewardProviderClient.cs @@ -7,6 +7,8 @@ public interface IRewardProviderClient { Task<(Wallet wallet, Models.Provider.WalletCreationStatus status)> CreateWallet(WalletRequestCreate request); + Task UpdateWalletUsername(string usernameCurrent, string username); + Task GetWallet(string walletId); Task> ListWalletVouchers(string walletId, int? limit, int? offset); diff --git a/src/api/src/domain/Yoma.Core.Domain/Reward/Models/WalletBalance.cs b/src/api/src/domain/Yoma.Core.Domain/Reward/Models/WalletBalance.cs index c4ebbf667..c3f04cfc4 100644 --- a/src/api/src/domain/Yoma.Core.Domain/Reward/Models/WalletBalance.cs +++ b/src/api/src/domain/Yoma.Core.Domain/Reward/Models/WalletBalance.cs @@ -4,6 +4,8 @@ public class WalletBalance { public string? WalletId { get; set; } + public string? WalletUsername { get; set; } + public decimal Available { get; set; } public decimal Pending { get; set; } diff --git a/src/api/src/domain/Yoma.Core.Domain/Reward/Models/WalletCreation.cs b/src/api/src/domain/Yoma.Core.Domain/Reward/Models/WalletCreation.cs index 6c6dfa9c8..fedd10930 100644 --- a/src/api/src/domain/Yoma.Core.Domain/Reward/Models/WalletCreation.cs +++ b/src/api/src/domain/Yoma.Core.Domain/Reward/Models/WalletCreation.cs @@ -10,6 +10,8 @@ public class WalletCreation public Guid UserId { get; set; } + public string? Username { get; set; } + public string? WalletId { get; set; } public decimal? Balance { get; set; } diff --git a/src/api/src/domain/Yoma.Core.Domain/Reward/Services/RewardBackgroundService.cs b/src/api/src/domain/Yoma.Core.Domain/Reward/Services/RewardBackgroundService.cs index 16f02d97d..b3f6314b4 100644 --- a/src/api/src/domain/Yoma.Core.Domain/Reward/Services/RewardBackgroundService.cs +++ b/src/api/src/domain/Yoma.Core.Domain/Reward/Services/RewardBackgroundService.cs @@ -74,7 +74,7 @@ public async Task ProcessWalletCreation() _logger.LogInformation("Lock '{lockIdentifier}' acquired by {hostName} at {dateStamp}. Lock duration set to {lockDurationInMinutes} minutes", lockIdentifier, Environment.MachineName, DateTimeOffset.UtcNow, lockDuration.TotalMinutes); - _logger.LogInformation("Processing Reward wallet creation"); + _logger.LogInformation("Processing reward wallet creation"); var itemIdsToSkip = new List(); while (executeUntil > DateTimeOffset.UtcNow) @@ -84,33 +84,59 @@ public async Task ProcessWalletCreation() foreach (var item in items) { + var pendingStatus = item.Status; try { _logger.LogInformation("Processing reward wallet creation for item with id '{id}'", item.Id); - await _executionStrategyService.ExecuteInExecutionStrategyAsync(async () => + switch (pendingStatus) { - using var scope = new TransactionScope(TransactionScopeOption.RequiresNew, TransactionScopeAsyncFlowOption.Enabled); + case WalletCreationStatus.Pending: + _logger.LogInformation("Creating reward wallet"); - var wallet = await _walletService.CreateWallet(item.UserId); + await _executionStrategyService.ExecuteInExecutionStrategyAsync(async () => + { + using var scope = new TransactionScope(TransactionScopeOption.RequiresNew, TransactionScopeAsyncFlowOption.Enabled); - item.WalletId = wallet.Id; - item.Balance = wallet.Balance; //track initial balance upon creation, if any - item.Status = WalletCreationStatus.Created; - await _walletService.UpdateScheduleCreation(item); + var (username, wallet) = await _walletService.CreateWallet(item.UserId); - scope.Complete(); - }); + item.Username = username; + item.WalletId = wallet.Id; + item.Balance = wallet.Balance; //track initial balance upon creation, if any + item.Status = WalletCreationStatus.Created; + await _walletService.UpdateScheduleCreation(item, pendingStatus); + + scope.Complete(); + }); + + _logger.LogInformation("Created reward wallet"); + break; + + case WalletCreationStatus.PendingUsernameUpdate: + _logger.LogInformation("Updating reward wallet username"); + + var username = await _walletService.UpdateWalletUsername(item.UserId); + + item.Username = username; + item.Status = WalletCreationStatus.Created; + await _walletService.UpdateScheduleCreation(item, pendingStatus); + + _logger.LogInformation("Updated reward wallet username"); + break; + + default: + throw new InvalidOperationException($"Pending status of '{pendingStatus}' not supported"); + } _logger.LogInformation("Processed reward wallet creation for item with id '{id}'", item.Id); } catch (Exception ex) { - _logger.LogError(ex, "Failed to created reward wallet for item with id '{id}'", item.Id); + _logger.LogError(ex, "Failed to proceess reward wallet creation for item with id '{id}'", item.Id); item.Status = WalletCreationStatus.Error; item.ErrorReason = ex.Message; - await _walletService.UpdateScheduleCreation(item); + await _walletService.UpdateScheduleCreation(item, pendingStatus); itemIdsToSkip.Add(item.Id); } @@ -182,8 +208,8 @@ public async Task ProcessRewardTransactions() var request = new RewardAwardRequest { Type = sourceEntityType, - Username = userEmail, - UserWalletId = walletId, + Username = userEmail!, + UserWalletId = walletId!, Amount = item.Amount }; @@ -254,17 +280,17 @@ public async Task ProcessRewardTransactions() #endregion #region Private Members - private (bool proceed, string userEmail, string walletId) GetWalletId(RewardTransaction item, Guid userId) + private (bool proceed, string? username, string? walletId) GetWalletId(RewardTransaction item, Guid userId) { - var (userEmail, walletId) = _walletService.GetWalletIdOrNull(userId); + var (username, walletId) = _walletService.GetWalletIdOrNull(userId); if (string.IsNullOrEmpty(walletId)) { _logger.LogInformation( "Processing of reward transaction for item with id '{itemId}' " + "was skipped as the wallet creation for user with id '{userId}' has not been completed", item.Id, userId); - return (false, userEmail, string.Empty); + return (false, null, null); } - return (true, userEmail, walletId); + return (true, username, walletId); } #endregion } diff --git a/src/api/src/domain/Yoma.Core.Domain/Reward/Services/WalletService.cs b/src/api/src/domain/Yoma.Core.Domain/Reward/Services/WalletService.cs index 67e26d551..caf8ea700 100644 --- a/src/api/src/domain/Yoma.Core.Domain/Reward/Services/WalletService.cs +++ b/src/api/src/domain/Yoma.Core.Domain/Reward/Services/WalletService.cs @@ -3,26 +3,26 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using System.Transactions; +using Yoma.Core.Domain.Core; using Yoma.Core.Domain.Core.Exceptions; using Yoma.Core.Domain.Core.Helpers; using Yoma.Core.Domain.Core.Interfaces; using Yoma.Core.Domain.Core.Models; using Yoma.Core.Domain.Entity.Interfaces; +using Yoma.Core.Domain.Entity.Models; using Yoma.Core.Domain.Reward.Interfaces; using Yoma.Core.Domain.Reward.Interfaces.Lookups; using Yoma.Core.Domain.Reward.Interfaces.Provider; using Yoma.Core.Domain.Reward.Models; using Yoma.Core.Domain.Reward.Models.Provider; using Yoma.Core.Domain.Reward.Validators; -using Yoma.Core.Domain.SSI; -using Yoma.Core.Domain.SSI.Services; namespace Yoma.Core.Domain.Reward.Services { public class WalletService : IWalletService { #region Class Variables - private readonly ILogger _logger; + private readonly ILogger _logger; private readonly AppSettings _appSettings; private readonly IHttpContextAccessor _httpContextAccessor; private readonly IRewardProviderClient _rewardProviderClient; @@ -35,7 +35,7 @@ public class WalletService : IWalletService #endregion #region Constructor - public WalletService(ILogger logger, + public WalletService(ILogger logger, IOptions appSettings, IHttpContextAccessor httpContextAccessor, IRewardProviderClientFactory rewardProviderClientFactory, @@ -60,15 +60,20 @@ public WalletService(ILogger logger, #endregion #region Public Members - public (string userEmail, string walletId) GetWalletId(Guid userId) + public (string username, string walletId) GetWalletId(Guid userId) { - var (userEmail, walletId) = GetWalletIdOrNull(userId); + var (username, walletId) = GetWalletIdOrNull(userId); + if (string.IsNullOrEmpty(walletId)) throw new EntityNotFoundException($"Wallet id not found for user with id '{userId}'"); - return (userEmail, walletId); + + if (string.IsNullOrEmpty(username)) + throw new InvalidOperationException($"Wallet id found for user with id '{userId}' but username is empty"); + + return (username, walletId); } - public (string userEmail, string? walletId) GetWalletIdOrNull(Guid userId) + public (string? username, string? walletId) GetWalletIdOrNull(Guid userId) { var user = _userService.GetById(userId, false, false); @@ -79,7 +84,7 @@ public WalletService(ILogger logger, if (result != null && string.IsNullOrEmpty(result.WalletId)) throw new DataInconsistencyException($"Wallet id expected with wallet creation status of '{WalletCreationStatus.Created}' for item with id '{result.Id}'"); - return (user.Email, result?.WalletId); + return (result?.Username, result?.WalletId); } public async Task<(WalletCreationStatus status, WalletBalance balance)> GetWalletStatusAndBalance(Guid userId) @@ -99,6 +104,8 @@ public WalletService(ILogger logger, case WalletCreationStatus.Pending: case WalletCreationStatus.Error: break; + + case WalletCreationStatus.PendingUsernameUpdate: case WalletCreationStatus.Created: if (item == null) throw new InvalidOperationException($"Wallet creation item excepted with status '{status}'"); @@ -107,6 +114,7 @@ public WalletService(ILogger logger, throw new DataInconsistencyException($"Wallet id expected with wallet creation status of 'Created' for item with id '{item.Id}'"); balance.WalletId = item.WalletId; + balance.WalletUsername = item.Username; try { @@ -133,7 +141,7 @@ public async Task SearchVouchers(WalletVoucherSearch await _walletVoucherSearchFilterValidator.ValidateAndThrowAsync(filter); - var user = _userService.GetByEmail(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, false), false, false); + var user = _userService.GetByUsername(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, false), false, false); var item = _walletCreationRepository.Query().SingleOrDefault(o => o.UserId == user.Id) ?? throw new ValidationException($"Wallet creation for the user with email '{user.Email}' hasn't been scheduled. Kindly log out and log back in"); @@ -153,7 +161,7 @@ public async Task SearchVouchers(WalletVoucherSearch return result; } - public async Task CreateWallet(Guid userId) + public async Task<(string username, Wallet wallet)> CreateWallet(Guid userId) { if (userId == Guid.Empty) throw new ArgumentNullException(nameof(userId)); @@ -164,12 +172,13 @@ public async Task CreateWallet(Guid userId) //query pending rewards and calculate balance var balance = rewardTransactions.Sum(o => o.Amount); + var username = ParseWalletUsername(user); //attempt wallet creation var request = new WalletRequestCreate { - Username = user.Email, - DisplayName = user.DisplayName, + Username = username, + DisplayName = user.DisplayName ?? user.Username, Balance = balance }; @@ -194,7 +203,7 @@ public async Task CreateWallet(Guid userId) throw new InvalidOperationException($"Status of '{status}' not supported"); } - return wallet; + return (username, wallet); } public async Task CreateWalletOrScheduleCreation(Guid? userId) @@ -206,6 +215,42 @@ public async Task CreateWalletOrScheduleCreation(Guid? userId) if (existingItem != null) { _logger.LogInformation("Wallet creation skipped: Already '{status}' for user with id '{userId}'", existingItem.Status, userId.Value); + + if (existingItem.Status != WalletCreationStatus.Created) + { + _logger.LogInformation("Wallet username update skipped: Current status '{status}' for user with id '{userId}'", existingItem.Status, userId.Value); + return; + } + + var user = _userService.GetById(userId.Value, false, false); + + if (string.IsNullOrEmpty(existingItem.Username)) + throw new InvalidOperationException($"Created Wallet: Wallet username expected for user with id {userId.Value}"); + + var username = ParseWalletUsername(user); + + if (string.Equals(existingItem.Username, username, StringComparison.InvariantCultureIgnoreCase)) + { + _logger.LogInformation("Wallet username update skipped: Username is already up to date for user with id '{userId}'", userId.Value); + return; + } + + try + { + + await _rewardProviderClient.UpdateWalletUsername(existingItem.Username, username); + existingItem.Username = username; + //status remains created + } + catch + { + //schedule username update for delayed execution + existingItem.StatusId = _walletCreationStatusService.GetByName(WalletCreationStatus.PendingUsernameUpdate.ToString()).Id; + existingItem.Status = WalletCreationStatus.PendingUsernameUpdate; + } + + await _walletCreationRepository.Update(existingItem); + return; } @@ -216,17 +261,18 @@ await _executionStrategyService.ExecuteInExecutionStrategyAsync(async () => var item = new WalletCreation { UserId = userId.Value }; try { - var wallet = await CreateWallet(userId.Value); + var (username, wallet) = await CreateWallet(userId.Value); + item.Username = username; item.WalletId = wallet.Id; item.Balance = wallet.Balance; //track initial balance upon creation, if any - item.StatusId = _walletCreationStatusService.GetByName(TenantCreationStatus.Created.ToString()).Id; + item.StatusId = _walletCreationStatusService.GetByName(WalletCreationStatus.Created.ToString()).Id; item.Status = WalletCreationStatus.Created; } catch (Exception) { //schedule creation for delayed execution - item.StatusId = _walletCreationStatusService.GetByName(TenantCreationStatus.Pending.ToString()).Id; + item.StatusId = _walletCreationStatusService.GetByName(WalletCreationStatus.Pending.ToString()).Id; item.Status = WalletCreationStatus.Pending; } @@ -236,13 +282,42 @@ await _executionStrategyService.ExecuteInExecutionStrategyAsync(async () => }); } + public async Task UpdateWalletUsername(Guid userId) + { + if (userId == Guid.Empty) + throw new ArgumentNullException(nameof(userId)); + + var user = _userService.GetById(userId, false, false); + + var existingItem = _walletCreationRepository.Query().SingleOrDefault(o => o.UserId == userId) ?? throw new InvalidOperationException($"Wallet creation item expected for user with id '{userId}'"); + + if (existingItem.Status != WalletCreationStatus.PendingUsernameUpdate) + throw new InvalidOperationException($"Expected status '{WalletCreationStatus.PendingUsernameUpdate}', but found '{existingItem.Status}' for user with id '{userId}'"); + + if (string.IsNullOrEmpty(existingItem.Username)) + throw new InvalidOperationException($"Username expected for user with id '{userId}' with a created wallet"); + + var username = ParseWalletUsername(user); + + if (string.Equals(existingItem.Username, username, StringComparison.InvariantCultureIgnoreCase)) + return username; + + await _rewardProviderClient.UpdateWalletUsername(existingItem.Username, username); + + return username; + } + public List ListPendingCreationSchedule(int batchSize, List idsToSkip) { ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(batchSize, default, nameof(batchSize)); - var statusPendingId = _walletCreationStatusService.GetByName(TenantCreationStatus.Pending.ToString()).Id; + var statusPendingIds = new List + { + _walletCreationStatusService.GetByName(WalletCreationStatus.Pending.ToString()).Id, + _walletCreationStatusService.GetByName(WalletCreationStatus.PendingUsernameUpdate.ToString()).Id + }; - var query = _walletCreationRepository.Query().Where(o => o.StatusId == statusPendingId); + var query = _walletCreationRepository.Query().Where(o => statusPendingIds.Contains(o.StatusId)); if (idsToSkip != null && idsToSkip.Count != 0) query = query.Where(o => !idsToSkip.Contains(o.Id)); @@ -252,7 +327,7 @@ public List ListPendingCreationSchedule(int batchSize, List 0 && item.RetryCount > _appSettings.RewardMaximumRetryAttempts) break; - item.StatusId = _walletCreationStatusService.GetByName(WalletCreationStatus.Pending.ToString()).Id; - item.Status = WalletCreationStatus.Pending; + item.StatusId = _walletCreationStatusService.GetByName(retryStatusOnFailure.ToString()).Id; + + item.Status = retryStatusOnFailure switch + { + WalletCreationStatus.Pending or WalletCreationStatus.PendingUsernameUpdate => retryStatusOnFailure, + _ => throw new InvalidOperationException($"Retry status of '{retryStatusOnFailure}' not supported"), + }; break; default: @@ -294,5 +379,14 @@ public async Task UpdateScheduleCreation(WalletCreation item) await _walletCreationRepository.Update(item); } #endregion + + #region Private Members + private static string ParseWalletUsername(User user) + { + var username = user.Username; + if (!username.Contains('@')) username = $"{username}@{Constants.System_Domain}"; + return username; + } + #endregion } } diff --git a/src/api/src/domain/Yoma.Core.Domain/SSI/Services/SSIBackgroundService.cs b/src/api/src/domain/Yoma.Core.Domain/SSI/Services/SSIBackgroundService.cs index 54d51cdd0..29a31f4a0 100644 --- a/src/api/src/domain/Yoma.Core.Domain/SSI/Services/SSIBackgroundService.cs +++ b/src/api/src/domain/Yoma.Core.Domain/SSI/Services/SSIBackgroundService.cs @@ -163,7 +163,7 @@ public async Task ProcessTenantCreation() { // utilize user id, ensuring a consistent tenant reference or name even if the name is altered Referent = user.Id.ToString(), - Name = user.DisplayName.RemoveSpecialCharacters(), + Name = user.DisplayName?.RemoveSpecialCharacters() ?? user.Username, ImageUrl = user.PhotoURL, Roles = [Role.Holder] }; @@ -284,6 +284,7 @@ public async Task ProcessCredentialIssuance() if (!item.UserId.HasValue) throw new InvalidOperationException($"Schema type '{item.SchemaType}': 'User id is null"); user = _userService.GetById(item.UserId.Value, true, true); + user.DisplayName ??= user.Username; //default display name to username if null var organization = _organizationService.GetByNameOrNull(_appSettings.YomaOrganizationName, true, true); if (organization == null) diff --git a/src/api/src/domain/Yoma.Core.Domain/SSI/Services/SSIWalletService.cs b/src/api/src/domain/Yoma.Core.Domain/SSI/Services/SSIWalletService.cs index 742075fd4..0044d22eb 100644 --- a/src/api/src/domain/Yoma.Core.Domain/SSI/Services/SSIWalletService.cs +++ b/src/api/src/domain/Yoma.Core.Domain/SSI/Services/SSIWalletService.cs @@ -54,7 +54,7 @@ public async Task SearchUserCredentials(SSIWalletFilter { ArgumentNullException.ThrowIfNull(filter, nameof(filter)); - var user = _userService.GetByEmail(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, false), false, false); + var user = _userService.GetByUsername(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, false), false, false); filter.EntityType = Entity.EntityType.User; filter.EntityId = user.Id; @@ -66,7 +66,7 @@ public async Task SearchUserCredentials(SSIWalletFilter #region Private Members private string GetUserTenantId() { - var user = _userService.GetByEmail(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, false), false, false); + var user = _userService.GetByUsername(HttpContextAccessorHelper.GetUsername(_httpContextAccessor, false), false, false); var tenantId = _ssiTenantService.GetTenantId(Entity.EntityType.User, user.Id); return tenantId; } diff --git a/src/api/src/domain/Yoma.Core.Domain/Startup.cs b/src/api/src/domain/Yoma.Core.Domain/Startup.cs index 79aef096b..edd0c9078 100644 --- a/src/api/src/domain/Yoma.Core.Domain/Startup.cs +++ b/src/api/src/domain/Yoma.Core.Domain/Startup.cs @@ -13,8 +13,8 @@ using Yoma.Core.Domain.Core.Interfaces; using Yoma.Core.Domain.Core.Models; using Yoma.Core.Domain.Core.Services; -using Yoma.Core.Domain.EmailProvider.Interfaces; -using Yoma.Core.Domain.EmailProvider.Services; +using Yoma.Core.Domain.Notification.Interfaces; +using Yoma.Core.Domain.Notification.Services; using Yoma.Core.Domain.Entity; using Yoma.Core.Domain.Entity.Interfaces; using Yoma.Core.Domain.Entity.Interfaces.Lookups; @@ -84,11 +84,6 @@ public static void ConfigureServices_DomainServices(this IServiceCollection serv services.AddScoped(); #endregion Core - #region EmailProvider - services.AddScoped(); - services.AddScoped(); - #endregion EmailProvider - #region Entity #region Lookups services.AddScoped(); @@ -135,6 +130,12 @@ public static void ConfigureServices_DomainServices(this IServiceCollection serv services.AddScoped(); #endregion My Opportunity + #region EmailProvider + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + #endregion EmailProvider + #region Opportunity #region Lookups services.AddScoped(); diff --git a/src/api/src/infrastructure/Yoma.Core.Infrastructure.Database/Entity/Entities/User.cs b/src/api/src/infrastructure/Yoma.Core.Infrastructure.Database/Entity/Entities/User.cs index f74c88c87..fff8184f9 100644 --- a/src/api/src/infrastructure/Yoma.Core.Infrastructure.Database/Entity/Entities/User.cs +++ b/src/api/src/infrastructure/Yoma.Core.Infrastructure.Database/Entity/Entities/User.cs @@ -8,32 +8,31 @@ namespace Yoma.Core.Infrastructure.Database.Entity.Entities { [Table("User", Schema = "Entity")] [Index(nameof(Email), IsUnique = true)] - [Index(nameof(FirstName), nameof(Surname), nameof(EmailConfirmed), nameof(PhoneNumber), nameof(DateOfBirth), nameof(DateLastLogin), nameof(ExternalId), + [Index(nameof(PhoneNumber), IsUnique = true)] + [Index(nameof(ExternalId), IsUnique = true)] + [Index(nameof(FirstName), nameof(Surname), nameof(DisplayName), nameof(EmailConfirmed), nameof(PhoneNumberConfirmed), nameof(DateOfBirth), nameof(DateLastLogin), nameof(YoIDOnboarded), nameof(DateYoIDOnboarded), nameof(DateCreated), nameof(DateModified))] public class User : BaseEntity { - [Required] [Column(TypeName = "varchar(320)")] - public string Email { get; set; } + public string? Email { get; set; } - [Required] - public bool EmailConfirmed { get; set; } + public bool? EmailConfirmed { get; set; } - [Required] [Column(TypeName = "varchar(125)")] - public string FirstName { get; set; } + public string? FirstName { get; set; } - [Required] [Column(TypeName = "varchar(125)")] - public string Surname { get; set; } + public string? Surname { get; set; } - [Required] [Column(TypeName = "varchar(255)")] - public string DisplayName { get; set; } + public string? DisplayName { get; set; } [Column(TypeName = "varchar(50)")] public string? PhoneNumber { get; set; } + public bool? PhoneNumberConfirmed { get; set; } + [ForeignKey("CountryId")] public Guid? CountryId { get; set; } public Country? Country { get; set; } diff --git a/src/api/src/infrastructure/Yoma.Core.Infrastructure.Database/Entity/Repositories/OrganizationRepository.cs b/src/api/src/infrastructure/Yoma.Core.Infrastructure.Database/Entity/Repositories/OrganizationRepository.cs index 0bf269423..98c746f5c 100644 --- a/src/api/src/infrastructure/Yoma.Core.Infrastructure.Database/Entity/Repositories/OrganizationRepository.cs +++ b/src/api/src/infrastructure/Yoma.Core.Infrastructure.Database/Entity/Repositories/OrganizationRepository.cs @@ -81,10 +81,12 @@ public OrganizationRepository(ApplicationDbContext context) : base(context) { } entity.Administrators.Select(o => new Domain.Entity.Models.UserInfo { Id = o.UserId, + Username = o.User.Email ?? o.User.PhoneNumber ?? string.Empty, Email = o.User.Email, FirstName = o.User.FirstName, Surname = o.User.Surname, - DisplayName = o.User.DisplayName, + DisplayName = o.User.DisplayName ?? o.User.Email ?? o.User.PhoneNumber ?? string.Empty, + PhoneNumber = o.User.PhoneNumber, CountryId = o.User.CountryId }).OrderBy(o => o.DisplayName).ToList() : null diff --git a/src/api/src/infrastructure/Yoma.Core.Infrastructure.Database/Entity/Repositories/UserRepository.cs b/src/api/src/infrastructure/Yoma.Core.Infrastructure.Database/Entity/Repositories/UserRepository.cs index c893263a7..36a25936e 100644 --- a/src/api/src/infrastructure/Yoma.Core.Infrastructure.Database/Entity/Repositories/UserRepository.cs +++ b/src/api/src/infrastructure/Yoma.Core.Infrastructure.Database/Entity/Repositories/UserRepository.cs @@ -26,12 +26,14 @@ public UserRepository(ApplicationDbContext context) : base(context) { } return _context.User.Select(entity => new Domain.Entity.Models.User() { Id = entity.Id, + Username = entity.Email ?? entity.PhoneNumber ?? string.Empty, Email = entity.Email, EmailConfirmed = entity.EmailConfirmed, FirstName = entity.FirstName, Surname = entity.Surname, DisplayName = entity.DisplayName, PhoneNumber = entity.PhoneNumber, + PhoneNumberConfirmed = entity.PhoneNumberConfirmed, CountryId = entity.CountryId, Country = entity.Country == null ? null : entity.Country.Name, EducationId = entity.EducationId, @@ -71,13 +73,21 @@ public UserRepository(ApplicationDbContext context) : base(context) { } public Expression> Contains(Expression> predicate, string value) { //MS SQL: Contains - return predicate.Or(o => EF.Functions.ILike(o.FirstName, $"%{value}%") || EF.Functions.ILike(o.Surname, $"%{value}%") || EF.Functions.ILike(o.Email, $"%{value}%") || EF.Functions.ILike(o.DisplayName, $"%{value}%")); + return predicate.Or(o => (!string.IsNullOrEmpty(o.Email) && EF.Functions.ILike(o.Email, $"%{value}%")) + || (!string.IsNullOrEmpty(o.FirstName) && EF.Functions.ILike(o.FirstName, $"%{value}%")) + || (!string.IsNullOrEmpty(o.Surname) && EF.Functions.ILike(o.Surname, $"%{value}%")) + || (!string.IsNullOrEmpty(o.DisplayName) && EF.Functions.ILike(o.DisplayName, $"%{value}%")) + || (!string.IsNullOrEmpty(o.PhoneNumber) && EF.Functions.ILike(o.PhoneNumber, $"%{value}%"))); } public IQueryable Contains(IQueryable query, string value) { //MS SQL: Contains - return query.Where(o => EF.Functions.ILike(o.FirstName, $"%{value}%") || EF.Functions.ILike(o.Surname, $"%{value}%") || EF.Functions.ILike(o.Email, $"%{value}%") || EF.Functions.ILike(o.DisplayName, $"%{value}%")); + return query.Where(o => (!string.IsNullOrEmpty(o.Email) && EF.Functions.ILike(o.Email, $"%{value}%")) + || (!string.IsNullOrEmpty(o.FirstName) && EF.Functions.ILike(o.FirstName, $"%{value}%")) + || (!string.IsNullOrEmpty(o.Surname) && EF.Functions.ILike(o.Surname, $"%{value}%")) + || (!string.IsNullOrEmpty(o.DisplayName) && EF.Functions.ILike(o.DisplayName, $"%{value}%")) + || (!string.IsNullOrEmpty(o.PhoneNumber) && EF.Functions.ILike(o.PhoneNumber, $"%{value}%"))); } public async Task Create(Domain.Entity.Models.User item) @@ -96,6 +106,7 @@ public UserRepository(ApplicationDbContext context) : base(context) { } Surname = item.Surname, DisplayName = item.DisplayName, PhoneNumber = item.PhoneNumber, + PhoneNumberConfirmed = item.PhoneNumberConfirmed, CountryId = item.CountryId, EducationId = item.EducationId, PhotoId = item.PhotoId, @@ -129,6 +140,7 @@ public UserRepository(ApplicationDbContext context) : base(context) { } entity.Surname = item.Surname; entity.DisplayName = item.DisplayName; entity.PhoneNumber = item.PhoneNumber; + entity.PhoneNumberConfirmed = item.PhoneNumberConfirmed; entity.CountryId = item.CountryId; entity.EducationId = item.EducationId; entity.PhotoId = item.PhotoId; diff --git a/src/api/src/infrastructure/Yoma.Core.Infrastructure.Database/Migrations/20241023093106_ApplicationDb_Authentication_PhoneNumber.Designer.cs b/src/api/src/infrastructure/Yoma.Core.Infrastructure.Database/Migrations/20241023093106_ApplicationDb_Authentication_PhoneNumber.Designer.cs new file mode 100644 index 000000000..2d0391d9c --- /dev/null +++ b/src/api/src/infrastructure/Yoma.Core.Infrastructure.Database/Migrations/20241023093106_ApplicationDb_Authentication_PhoneNumber.Designer.cs @@ -0,0 +1,2790 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Yoma.Core.Infrastructure.Database.Context; + +#nullable disable + +namespace Yoma.Core.Infrastructure.Database.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20241023093106_ApplicationDb_Authentication_PhoneNumber")] + partial class ApplicationDb_Authentication_PhoneNumber + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.ActionLink.Entities.Link", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Action") + .IsRequired() + .HasColumnType("varchar(25)"); + + b.Property("CommentApproval") + .HasColumnType("varchar(500)"); + + b.Property("CreatedByUserId") + .HasColumnType("uuid"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("DateModified") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("varchar(500)"); + + b.Property("DistributionList") + .HasColumnType("text"); + + b.Property("EntityType") + .IsRequired() + .HasColumnType("varchar(25)"); + + b.Property("LockToDistributionList") + .HasColumnType("boolean"); + + b.Property("ModifiedByUserId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.Property("OpportunityId") + .HasColumnType("uuid"); + + b.Property("ShortURL") + .IsRequired() + .HasColumnType("varchar(2048)"); + + b.Property("StatusId") + .HasColumnType("uuid"); + + b.Property("URL") + .IsRequired() + .HasColumnType("varchar(2048)"); + + b.Property("UsagesLimit") + .HasColumnType("integer"); + + b.Property("UsagesTotal") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserId"); + + b.HasIndex("ModifiedByUserId"); + + b.HasIndex("OpportunityId"); + + b.HasIndex("ShortURL") + .IsUnique(); + + b.HasIndex("StatusId"); + + b.HasIndex("URL") + .IsUnique(); + + b.HasIndex("Name", "EntityType", "Action", "StatusId", "OpportunityId", "DateEnd", "DateCreated"); + + b.ToTable("Link", "ActionLink"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.ActionLink.Entities.LinkUsageLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("LinkId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DateCreated"); + + b.HasIndex("UserId"); + + b.HasIndex("LinkId", "UserId") + .IsUnique(); + + b.ToTable("UsageLog", "ActionLink"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.ActionLink.Entities.Lookups.LinkStatus", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("varchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Status", "ActionLink"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.Core.Entities.BlobObject", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ContentType") + .IsRequired() + .HasColumnType("varchar(127)"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("FileType") + .IsRequired() + .HasColumnType("varchar(25)"); + + b.Property("Key") + .IsRequired() + .HasColumnType("varchar(125)"); + + b.Property("OriginalFileName") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.Property("ParentId") + .HasColumnType("uuid"); + + b.Property("StorageType") + .IsRequired() + .HasColumnType("varchar(25)"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.HasIndex("ParentId"); + + b.HasIndex("StorageType", "FileType", "ParentId"); + + b.ToTable("Blob", "Object"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.Entity.Entities.Lookups.OrganizationProviderType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("OrganizationProviderType", "Entity"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.Entity.Entities.Lookups.OrganizationStatus", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("OrganizationStatus", "Entity"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.Entity.Entities.Lookups.SettingsDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DefaultValue") + .IsRequired() + .HasColumnType("varchar(50)"); + + b.Property("Description") + .IsRequired() + .HasColumnType("varchar(500)"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("EntityType") + .IsRequired() + .HasColumnType("varchar(25)"); + + b.Property("Group") + .IsRequired() + .HasColumnType("varchar(100)"); + + b.Property("Key") + .IsRequired() + .HasColumnType("varchar(100)"); + + b.Property("Order") + .HasColumnType("smallint"); + + b.Property("Roles") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("SubGroup") + .HasColumnType("varchar(100)"); + + b.Property("Title") + .IsRequired() + .HasColumnType("varchar(100)"); + + b.Property("Type") + .IsRequired() + .HasColumnType("varchar(50)"); + + b.Property("Visible") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("EntityType", "Key") + .IsUnique(); + + b.ToTable("SettingsDefinition", "Entity"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.Entity.Entities.Organization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Biography") + .HasColumnType("text"); + + b.Property("City") + .HasColumnType("varchar(50)"); + + b.Property("CommentApproval") + .HasColumnType("varchar(500)"); + + b.Property("CountryId") + .HasColumnType("uuid"); + + b.Property("CreatedByUserId") + .HasColumnType("uuid"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateModified") + .HasColumnType("timestamp with time zone"); + + b.Property("DateStatusModified") + .HasColumnType("timestamp with time zone"); + + b.Property("LogoId") + .HasColumnType("uuid"); + + b.Property("ModifiedByUserId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.Property("NameHashValue") + .IsRequired() + .HasColumnType("varchar(128)"); + + b.Property("PostalCode") + .HasColumnType("varchar(10)"); + + b.Property("PrimaryContactEmail") + .HasColumnType("varchar(320)"); + + b.Property("PrimaryContactName") + .HasColumnType("varchar(255)"); + + b.Property("PrimaryContactPhone") + .HasColumnType("varchar(50)"); + + b.Property("Province") + .HasColumnType("varchar(255)"); + + b.Property("RegistrationNumber") + .HasColumnType("varchar(255)"); + + b.Property("SSOClientIdInbound") + .HasColumnType("varchar(255)"); + + b.Property("SSOClientIdOutbound") + .HasColumnType("varchar(255)"); + + b.Property("Settings") + .HasColumnType("text"); + + b.Property("StatusId") + .HasColumnType("uuid"); + + b.Property("StreetAddress") + .HasColumnType("varchar(500)"); + + b.Property("Tagline") + .HasColumnType("text"); + + b.Property("TaxNumber") + .HasColumnType("varchar(255)"); + + b.Property("VATIN") + .HasColumnType("varchar(255)"); + + b.Property("WebsiteURL") + .HasColumnType("varchar(2048)"); + + b.Property("YomaRewardCumulative") + .HasColumnType("decimal(12,2)"); + + b.Property("YomaRewardPool") + .HasColumnType("decimal(12,2)"); + + b.Property("ZltoRewardCumulative") + .HasColumnType("decimal(12,2)"); + + b.Property("ZltoRewardPool") + .HasColumnType("decimal(12,2)"); + + b.HasKey("Id"); + + b.HasIndex("CountryId"); + + b.HasIndex("CreatedByUserId"); + + b.HasIndex("LogoId"); + + b.HasIndex("ModifiedByUserId"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("NameHashValue") + .IsUnique(); + + b.HasIndex("StatusId", "DateStatusModified", "DateCreated", "CreatedByUserId", "DateModified", "ModifiedByUserId"); + + b.ToTable("Organization", "Entity"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.Entity.Entities.OrganizationDocument", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("FileId") + .HasColumnType("uuid"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Type") + .IsRequired() + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("FileId") + .IsUnique(); + + b.HasIndex("OrganizationId", "Type", "DateCreated"); + + b.ToTable("OrganizationDocuments", "Entity"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.Entity.Entities.OrganizationProviderType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("ProviderTypeId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ProviderTypeId"); + + b.HasIndex("OrganizationId", "ProviderTypeId") + .IsUnique(); + + b.ToTable("OrganizationProviderTypes", "Entity"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.Entity.Entities.OrganizationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("OrganizationId", "UserId") + .IsUnique(); + + b.ToTable("OrganizationUsers", "Entity"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.Entity.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CountryId") + .HasColumnType("uuid"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateLastLogin") + .HasColumnType("timestamp with time zone"); + + b.Property("DateModified") + .HasColumnType("timestamp with time zone"); + + b.Property("DateOfBirth") + .HasColumnType("timestamp with time zone"); + + b.Property("DateYoIDOnboarded") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .HasColumnType("varchar(255)"); + + b.Property("EducationId") + .HasColumnType("uuid"); + + b.Property("Email") + .HasColumnType("varchar(320)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("ExternalId") + .HasColumnType("uuid"); + + b.Property("FirstName") + .HasColumnType("varchar(125)"); + + b.Property("GenderId") + .HasColumnType("uuid"); + + b.Property("PhoneNumber") + .HasColumnType("varchar(50)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("PhotoId") + .HasColumnType("uuid"); + + b.Property("Settings") + .HasColumnType("text"); + + b.Property("Surname") + .HasColumnType("varchar(125)"); + + b.Property("YoIDOnboarded") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("CountryId"); + + b.HasIndex("EducationId"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("ExternalId") + .IsUnique(); + + b.HasIndex("GenderId"); + + b.HasIndex("PhoneNumber") + .IsUnique(); + + b.HasIndex("PhotoId"); + + b.HasIndex("FirstName", "Surname", "DisplayName", "EmailConfirmed", "PhoneNumberConfirmed", "DateOfBirth", "DateLastLogin", "YoIDOnboarded", "DateYoIDOnboarded", "DateCreated", "DateModified"); + + b.ToTable("User", "Entity"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.Entity.Entities.UserLoginHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuthMethod") + .HasColumnType("varchar(255)"); + + b.Property("AuthType") + .HasColumnType("varchar(255)"); + + b.Property("ClientId") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("IpAddress") + .HasColumnType("varchar(39)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ClientId", "DateCreated"); + + b.ToTable("UserLoginHistory", "Entity"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.Entity.Entities.UserSkill", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("SkillId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SkillId"); + + b.HasIndex("UserId", "SkillId") + .IsUnique(); + + b.ToTable("UserSkills", "Entity"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.Entity.Entities.UserSkillOrganization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("UserSkillId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserSkillId", "OrganizationId") + .IsUnique(); + + b.ToTable("UserSkillOrganizations", "Entity"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.Lookups.Entities.Country", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CodeAlpha2") + .IsRequired() + .HasColumnType("varchar(2)"); + + b.Property("CodeAlpha3") + .IsRequired() + .HasColumnType("varchar(3)"); + + b.Property("CodeNumeric") + .IsRequired() + .HasColumnType("varchar(3)"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("varchar(125)"); + + b.HasKey("Id"); + + b.HasIndex("CodeAlpha2") + .IsUnique(); + + b.HasIndex("CodeAlpha3") + .IsUnique(); + + b.HasIndex("CodeNumeric") + .IsUnique(); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Country", "Lookup"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.Lookups.Entities.Education", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("varchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Education", "Lookup"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.Lookups.Entities.EngagementType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("varchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("EngagementType", "Lookup"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.Lookups.Entities.Gender", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("varchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Gender", "Lookup"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.Lookups.Entities.Language", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CodeAlpha2") + .IsRequired() + .HasColumnType("varchar(2)"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("varchar(125)"); + + b.HasKey("Id"); + + b.HasIndex("CodeAlpha2") + .IsUnique(); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Language", "Lookup"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.Lookups.Entities.Skill", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateModified") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("varchar(100)"); + + b.Property("InfoURL") + .HasColumnType("varchar(2048)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.HasKey("Id"); + + b.HasIndex("ExternalId") + .IsUnique(); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Skill", "Lookup"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.Lookups.Entities.TimeInterval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("varchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("TimeInterval", "Lookup"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.Marketplace.Entities.Lookups.StoreAccessControlRuleStatus", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("varchar(30)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("StoreAccessControlRuleStatus", "Marketplace"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.Marketplace.Entities.Lookups.TransactionStatus", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("varchar(30)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("TransactionStatus", "Marketplace"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.Marketplace.Entities.StoreAccessControlRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AgeFrom") + .HasColumnType("integer"); + + b.Property("AgeTo") + .HasColumnType("integer"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateModified") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("varchar(500)"); + + b.Property("GenderId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.Property("OpportunityOption") + .HasColumnType("varchar(10)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("StatusId") + .HasColumnType("uuid"); + + b.Property("StoreCountryId") + .HasColumnType("uuid"); + + b.Property("StoreId") + .IsRequired() + .HasColumnType("varchar(50)"); + + b.Property("StoreItemCategories") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("GenderId"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("StatusId"); + + b.HasIndex("StoreCountryId"); + + b.HasIndex("Name", "OrganizationId", "StoreId", "StatusId", "DateCreated", "DateModified"); + + b.ToTable("StoreAccessControlRule", "Marketplace"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.Marketplace.Entities.StoreAccessControlRuleOpportunity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("OpportunityId") + .HasColumnType("uuid"); + + b.Property("StoreAccessControlRuleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OpportunityId"); + + b.HasIndex("StoreAccessControlRuleId", "OpportunityId") + .IsUnique(); + + b.ToTable("StoreAccessControlRuleOpportunity", "Marketplace"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.Marketplace.Entities.TransactionLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasColumnType("decimal(8,2)"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateModified") + .HasColumnType("timestamp with time zone"); + + b.Property("ItemCategoryId") + .IsRequired() + .HasColumnType("varchar(50)"); + + b.Property("ItemId") + .IsRequired() + .HasColumnType("varchar(50)"); + + b.Property("StatusId") + .HasColumnType("uuid"); + + b.Property("TransactionId") + .IsRequired() + .HasColumnType("varchar(50)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("StatusId"); + + b.HasIndex("UserId", "ItemCategoryId", "ItemId", "StatusId", "DateCreated", "DateModified"); + + b.ToTable("TransactionLog", "Marketplace"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.MyOpportunity.Entities.Lookups.MyOpportunityAction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("varchar(125)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("MyOpportunityAction", "Opportunity"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.MyOpportunity.Entities.Lookups.MyOpportunityVerificationStatus", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("varchar(125)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("MyOpportunityVerificationStatus", "Opportunity"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.MyOpportunity.Entities.MyOpportunity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionId") + .HasColumnType("uuid"); + + b.Property("CommentVerification") + .HasColumnType("varchar(500)"); + + b.Property("DateCompleted") + .HasColumnType("timestamp with time zone"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("DateModified") + .HasColumnType("timestamp with time zone"); + + b.Property("DateStart") + .HasColumnType("timestamp with time zone"); + + b.Property("OpportunityId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("VerificationStatusId") + .HasColumnType("uuid"); + + b.Property("YomaReward") + .HasColumnType("decimal(8,2)"); + + b.Property("ZltoReward") + .HasColumnType("decimal(8,2)"); + + b.HasKey("Id"); + + b.HasIndex("ActionId"); + + b.HasIndex("OpportunityId"); + + b.HasIndex("UserId", "OpportunityId", "ActionId") + .IsUnique(); + + b.HasIndex("VerificationStatusId", "DateCompleted", "ZltoReward", "YomaReward", "DateCreated", "DateModified"); + + b.ToTable("MyOpportunity", "Opportunity"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.MyOpportunity.Entities.MyOpportunityVerification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("FileId") + .HasColumnType("uuid"); + + b.Property("GeometryProperties") + .HasColumnType("text"); + + b.Property("MyOpportunityId") + .HasColumnType("uuid"); + + b.Property("VerificationTypeId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("FileId"); + + b.HasIndex("VerificationTypeId"); + + b.HasIndex("MyOpportunityId", "VerificationTypeId") + .IsUnique(); + + b.ToTable("MyOpportunityVerifications", "Opportunity"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.Opportunity.Entities.Lookups.OpportunityCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("ImageURL") + .IsRequired() + .HasColumnType("varchar(2048)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("varchar(125)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("OpportunityCategory", "Opportunity"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.Opportunity.Entities.Lookups.OpportunityDifficulty", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("varchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("OpportunityDifficulty", "Opportunity"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.Opportunity.Entities.Lookups.OpportunityStatus", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("varchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("OpportunityStatus", "Opportunity"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.Opportunity.Entities.Lookups.OpportunityType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("varchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("OpportunityType", "Opportunity"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.Opportunity.Entities.Lookups.OpportunityVerificationType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("varchar(125)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("varchar(125)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("OpportunityVerificationType", "Opportunity"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.Opportunity.Entities.Opportunity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CommitmentIntervalCount") + .HasColumnType("smallint"); + + b.Property("CommitmentIntervalId") + .HasColumnType("uuid"); + + b.Property("CreatedByUserId") + .HasColumnType("uuid"); + + b.Property("CredentialIssuanceEnabled") + .HasColumnType("boolean"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("DateModified") + .HasColumnType("timestamp with time zone"); + + b.Property("DateStart") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("DifficultyId") + .HasColumnType("uuid"); + + b.Property("EngagementTypeId") + .HasColumnType("uuid"); + + b.Property("Featured") + .HasColumnType("boolean"); + + b.Property("Instructions") + .HasColumnType("text"); + + b.Property("Keywords") + .HasColumnType("varchar(500)"); + + b.Property("ModifiedByUserId") + .HasColumnType("uuid"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("ParticipantCount") + .HasColumnType("integer"); + + b.Property("ParticipantLimit") + .HasColumnType("integer"); + + b.Property("SSISchemaName") + .HasColumnType("varchar(255)"); + + b.Property("ShareWithPartners") + .HasColumnType("boolean"); + + b.Property("StatusId") + .HasColumnType("uuid"); + + b.Property("Summary") + .HasColumnType("varchar(500)"); + + b.Property("Title") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.Property("TypeId") + .HasColumnType("uuid"); + + b.Property("URL") + .HasColumnType("varchar(2048)"); + + b.Property("VerificationEnabled") + .HasColumnType("boolean"); + + b.Property("VerificationMethod") + .HasColumnType("varchar(20)"); + + b.Property("YomaReward") + .HasColumnType("decimal(8,2)"); + + b.Property("YomaRewardCumulative") + .HasColumnType("decimal(12,2)"); + + b.Property("YomaRewardPool") + .HasColumnType("decimal(12,2)"); + + b.Property("ZltoReward") + .HasColumnType("decimal(8,2)"); + + b.Property("ZltoRewardCumulative") + .HasColumnType("decimal(12,2)"); + + b.Property("ZltoRewardPool") + .HasColumnType("decimal(12,2)"); + + b.HasKey("Id"); + + b.HasIndex("CommitmentIntervalId"); + + b.HasIndex("CreatedByUserId"); + + b.HasIndex("Description") + .HasAnnotation("Npgsql:TsVectorConfig", "english"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Description"), "GIN"); + + b.HasIndex("DifficultyId"); + + b.HasIndex("EngagementTypeId"); + + b.HasIndex("ModifiedByUserId"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("StatusId"); + + b.HasIndex("Title") + .IsUnique(); + + b.HasIndex("TypeId", "OrganizationId", "ZltoReward", "DifficultyId", "CommitmentIntervalId", "CommitmentIntervalCount", "StatusId", "Keywords", "DateStart", "DateEnd", "CredentialIssuanceEnabled", "Featured", "EngagementTypeId", "ShareWithPartners", "DateCreated", "CreatedByUserId", "DateModified", "ModifiedByUserId"); + + b.ToTable("Opportunity", "Opportunity"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.Opportunity.Entities.OpportunityCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CategoryId") + .HasColumnType("uuid"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("OpportunityId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.HasIndex("OpportunityId", "CategoryId") + .IsUnique(); + + b.ToTable("OpportunityCategories", "Opportunity"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.Opportunity.Entities.OpportunityCountry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CountryId") + .HasColumnType("uuid"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("OpportunityId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CountryId"); + + b.HasIndex("OpportunityId", "CountryId") + .IsUnique(); + + b.ToTable("OpportunityCountries", "Opportunity"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.Opportunity.Entities.OpportunityLanguage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("LanguageId") + .HasColumnType("uuid"); + + b.Property("OpportunityId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("LanguageId"); + + b.HasIndex("OpportunityId", "LanguageId") + .IsUnique(); + + b.ToTable("OpportunityLanguages", "Opportunity"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.Opportunity.Entities.OpportunitySkill", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("OpportunityId") + .HasColumnType("uuid"); + + b.Property("SkillId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SkillId"); + + b.HasIndex("OpportunityId", "SkillId") + .IsUnique(); + + b.ToTable("OpportunitySkills", "Opportunity"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.Opportunity.Entities.OpportunityVerificationType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateModified") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("varchar(255)"); + + b.Property("OpportunityId") + .HasColumnType("uuid"); + + b.Property("VerificationTypeId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("VerificationTypeId"); + + b.HasIndex("OpportunityId", "VerificationTypeId") + .IsUnique(); + + b.ToTable("OpportunityVerificationTypes", "Opportunity"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.PartnerSharing.Entities.Lookups.Partner", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionEnabled") + .HasColumnType("text"); + + b.Property("Active") + .HasColumnType("boolean"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("varchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Partner", "PartnerSharing"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.PartnerSharing.Entities.Lookups.ProcessingStatus", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("varchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ProcessingStatus", "PartnerSharing"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.PartnerSharing.Entities.ProcessingLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Action") + .IsRequired() + .HasColumnType("varchar(25)"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateModified") + .HasColumnType("timestamp with time zone"); + + b.Property("EntityExternalId") + .HasColumnType("varchar(50)"); + + b.Property("EntityType") + .IsRequired() + .HasColumnType("varchar(25)"); + + b.Property("ErrorReason") + .HasColumnType("text"); + + b.Property("OpportunityId") + .HasColumnType("uuid"); + + b.Property("PartnerId") + .HasColumnType("uuid"); + + b.Property("RetryCount") + .HasColumnType("smallint"); + + b.Property("StatusId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OpportunityId"); + + b.HasIndex("PartnerId"); + + b.HasIndex("StatusId"); + + b.HasIndex("EntityType", "OpportunityId", "PartnerId", "Action", "StatusId", "EntityExternalId", "DateCreated", "DateModified"); + + b.ToTable("ProcessingLog", "PartnerSharing"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.Reward.Entities.Lookups.RewardTransactionStatus", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("varchar(30)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("IX_TransactionStatus_Name1"); + + b.ToTable("TransactionStatus", "Reward"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.Reward.Entities.Lookups.WalletCreationStatus", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("WalletCreationStatus", "Reward"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.Reward.Entities.RewardTransaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasColumnType("decimal(8,2)"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateModified") + .HasColumnType("timestamp with time zone"); + + b.Property("ErrorReason") + .HasColumnType("text"); + + b.Property("MyOpportunityId") + .HasColumnType("uuid"); + + b.Property("RetryCount") + .HasColumnType("smallint"); + + b.Property("SourceEntityType") + .IsRequired() + .HasColumnType("varchar(25)"); + + b.Property("StatusId") + .HasColumnType("uuid"); + + b.Property("TransactionId") + .HasColumnType("varchar(50)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("MyOpportunityId"); + + b.HasIndex("StatusId", "DateCreated", "DateModified"); + + b.HasIndex("UserId", "SourceEntityType", "MyOpportunityId") + .IsUnique(); + + b.ToTable("Transaction", "Reward"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.Reward.Entities.WalletCreation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Balance") + .HasColumnType("decimal(12,2)"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateModified") + .HasColumnType("timestamp with time zone"); + + b.Property("ErrorReason") + .HasColumnType("text"); + + b.Property("RetryCount") + .HasColumnType("smallint"); + + b.Property("StatusId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("Username") + .HasColumnType("varchar(320)"); + + b.Property("WalletId") + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.HasIndex("StatusId", "DateCreated", "DateModified"); + + b.ToTable("WalletCreation", "Reward"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.SSI.Entities.Lookups.SSICredentialIssuanceStatus", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("varchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("CredentialIssuanceStatus", "SSI"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.SSI.Entities.Lookups.SSISchemaEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("TypeName") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.HasKey("Id"); + + b.HasIndex("TypeName") + .IsUnique(); + + b.ToTable("SchemaEntity", "SSI"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.SSI.Entities.Lookups.SSISchemaEntityProperty", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasColumnType("varchar(125)"); + + b.Property("Format") + .HasColumnType("varchar(125)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("varchar(50)"); + + b.Property("NameDisplay") + .IsRequired() + .HasColumnType("varchar(50)"); + + b.Property("Required") + .HasColumnType("boolean"); + + b.Property("SSISchemaEntityId") + .HasColumnType("uuid"); + + b.Property("SystemType") + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("SSISchemaEntityId", "Name") + .IsUnique(); + + b.ToTable("SchemaEntityProperty", "SSI"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.SSI.Entities.Lookups.SSISchemaEntityType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("SSISchemaEntityId") + .HasColumnType("uuid"); + + b.Property("SSISchemaTypeId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SSISchemaTypeId"); + + b.HasIndex("SSISchemaEntityId", "SSISchemaTypeId") + .IsUnique(); + + b.ToTable("SchemaEntityType", "SSI"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.SSI.Entities.Lookups.SSISchemaType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("varchar(125)"); + + b.Property("SupportMultiple") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("SchemaType", "SSI"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.SSI.Entities.Lookups.SSITenantCreationStatus", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("varchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("TenantCreationStatus", "SSI"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.SSI.Entities.SSICredentialIssuance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ArtifactType") + .IsRequired() + .HasColumnType("varchar(25)"); + + b.Property("CredentialId") + .HasColumnType("varchar(50)"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateModified") + .HasColumnType("timestamp with time zone"); + + b.Property("ErrorReason") + .HasColumnType("text"); + + b.Property("MyOpportunityId") + .HasColumnType("uuid"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RetryCount") + .HasColumnType("smallint"); + + b.Property("SchemaName") + .IsRequired() + .HasColumnType("varchar(125)"); + + b.Property("SchemaTypeId") + .HasColumnType("uuid"); + + b.Property("SchemaVersion") + .IsRequired() + .HasColumnType("varchar(20)"); + + b.Property("StatusId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("MyOpportunityId"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("StatusId"); + + b.HasIndex("UserId"); + + b.HasIndex("SchemaName", "UserId", "OrganizationId", "MyOpportunityId") + .IsUnique(); + + b.HasIndex("SchemaTypeId", "ArtifactType", "SchemaName", "StatusId", "DateCreated", "DateModified"); + + b.ToTable("CredentialIssuance", "SSI"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.SSI.Entities.SSITenantCreation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateModified") + .HasColumnType("timestamp with time zone"); + + b.Property("EntityType") + .IsRequired() + .HasColumnType("varchar(25)"); + + b.Property("ErrorReason") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RetryCount") + .HasColumnType("smallint"); + + b.Property("StatusId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("varchar(50)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId"); + + b.HasIndex("EntityType", "UserId", "OrganizationId") + .IsUnique(); + + b.HasIndex("StatusId", "DateCreated", "DateModified"); + + b.ToTable("TenantCreation", "SSI"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.ActionLink.Entities.Link", b => + { + b.HasOne("Yoma.Core.Infrastructure.Database.Entity.Entities.User", "CreatedByUser") + .WithMany() + .HasForeignKey("CreatedByUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Yoma.Core.Infrastructure.Database.Entity.Entities.User", "ModifiedByUser") + .WithMany() + .HasForeignKey("ModifiedByUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Yoma.Core.Infrastructure.Database.Opportunity.Entities.Opportunity", "Opportunity") + .WithMany() + .HasForeignKey("OpportunityId"); + + b.HasOne("Yoma.Core.Infrastructure.Database.ActionLink.Entities.Lookups.LinkStatus", "Status") + .WithMany() + .HasForeignKey("StatusId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CreatedByUser"); + + b.Navigation("ModifiedByUser"); + + b.Navigation("Opportunity"); + + b.Navigation("Status"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.ActionLink.Entities.LinkUsageLog", b => + { + b.HasOne("Yoma.Core.Infrastructure.Database.ActionLink.Entities.Link", "Link") + .WithMany() + .HasForeignKey("LinkId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Yoma.Core.Infrastructure.Database.Entity.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Link"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.Core.Entities.BlobObject", b => + { + b.HasOne("Yoma.Core.Infrastructure.Database.Core.Entities.BlobObject", "Parent") + .WithMany() + .HasForeignKey("ParentId"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.Entity.Entities.Organization", b => + { + b.HasOne("Yoma.Core.Infrastructure.Database.Lookups.Entities.Country", "Country") + .WithMany() + .HasForeignKey("CountryId"); + + b.HasOne("Yoma.Core.Infrastructure.Database.Entity.Entities.User", "CreatedByUser") + .WithMany() + .HasForeignKey("CreatedByUserId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Yoma.Core.Infrastructure.Database.Core.Entities.BlobObject", "Logo") + .WithMany() + .HasForeignKey("LogoId"); + + b.HasOne("Yoma.Core.Infrastructure.Database.Entity.Entities.User", "ModifiedByUser") + .WithMany() + .HasForeignKey("ModifiedByUserId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Yoma.Core.Infrastructure.Database.Entity.Entities.Lookups.OrganizationStatus", "Status") + .WithMany() + .HasForeignKey("StatusId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Country"); + + b.Navigation("CreatedByUser"); + + b.Navigation("Logo"); + + b.Navigation("ModifiedByUser"); + + b.Navigation("Status"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.Entity.Entities.OrganizationDocument", b => + { + b.HasOne("Yoma.Core.Infrastructure.Database.Core.Entities.BlobObject", "File") + .WithMany() + .HasForeignKey("FileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Yoma.Core.Infrastructure.Database.Entity.Entities.Organization", "Organization") + .WithMany("Documents") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("File"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.Entity.Entities.OrganizationProviderType", b => + { + b.HasOne("Yoma.Core.Infrastructure.Database.Entity.Entities.Organization", "Organization") + .WithMany("ProviderTypes") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Yoma.Core.Infrastructure.Database.Entity.Entities.Lookups.OrganizationProviderType", "ProviderType") + .WithMany() + .HasForeignKey("ProviderTypeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("ProviderType"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.Entity.Entities.OrganizationUser", b => + { + b.HasOne("Yoma.Core.Infrastructure.Database.Entity.Entities.Organization", "Organization") + .WithMany("Administrators") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Yoma.Core.Infrastructure.Database.Entity.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.Entity.Entities.User", b => + { + b.HasOne("Yoma.Core.Infrastructure.Database.Lookups.Entities.Country", "Country") + .WithMany() + .HasForeignKey("CountryId"); + + b.HasOne("Yoma.Core.Infrastructure.Database.Lookups.Entities.Education", "Education") + .WithMany() + .HasForeignKey("EducationId"); + + b.HasOne("Yoma.Core.Infrastructure.Database.Lookups.Entities.Gender", "Gender") + .WithMany() + .HasForeignKey("GenderId"); + + b.HasOne("Yoma.Core.Infrastructure.Database.Core.Entities.BlobObject", "Photo") + .WithMany() + .HasForeignKey("PhotoId"); + + b.Navigation("Country"); + + b.Navigation("Education"); + + b.Navigation("Gender"); + + b.Navigation("Photo"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.Entity.Entities.UserLoginHistory", b => + { + b.HasOne("Yoma.Core.Infrastructure.Database.Entity.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.Entity.Entities.UserSkill", b => + { + b.HasOne("Yoma.Core.Infrastructure.Database.Lookups.Entities.Skill", "Skill") + .WithMany() + .HasForeignKey("SkillId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Yoma.Core.Infrastructure.Database.Entity.Entities.User", "User") + .WithMany("Skills") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Skill"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.Entity.Entities.UserSkillOrganization", b => + { + b.HasOne("Yoma.Core.Infrastructure.Database.Entity.Entities.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Yoma.Core.Infrastructure.Database.Entity.Entities.UserSkill", "UserSkill") + .WithMany("Organizations") + .HasForeignKey("UserSkillId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("UserSkill"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.Marketplace.Entities.StoreAccessControlRule", b => + { + b.HasOne("Yoma.Core.Infrastructure.Database.Lookups.Entities.Gender", "Gender") + .WithMany() + .HasForeignKey("GenderId"); + + b.HasOne("Yoma.Core.Infrastructure.Database.Entity.Entities.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Yoma.Core.Infrastructure.Database.Marketplace.Entities.Lookups.StoreAccessControlRuleStatus", "Status") + .WithMany() + .HasForeignKey("StatusId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Yoma.Core.Infrastructure.Database.Lookups.Entities.Country", "StoreCountry") + .WithMany() + .HasForeignKey("StoreCountryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Gender"); + + b.Navigation("Organization"); + + b.Navigation("Status"); + + b.Navigation("StoreCountry"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.Marketplace.Entities.StoreAccessControlRuleOpportunity", b => + { + b.HasOne("Yoma.Core.Infrastructure.Database.Opportunity.Entities.Opportunity", "Opportunity") + .WithMany() + .HasForeignKey("OpportunityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Yoma.Core.Infrastructure.Database.Marketplace.Entities.StoreAccessControlRule", "StoreAccessControlRule") + .WithMany("Opportunities") + .HasForeignKey("StoreAccessControlRuleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Opportunity"); + + b.Navigation("StoreAccessControlRule"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.Marketplace.Entities.TransactionLog", b => + { + b.HasOne("Yoma.Core.Infrastructure.Database.Marketplace.Entities.Lookups.TransactionStatus", "Status") + .WithMany() + .HasForeignKey("StatusId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Yoma.Core.Infrastructure.Database.Entity.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Status"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.MyOpportunity.Entities.MyOpportunity", b => + { + b.HasOne("Yoma.Core.Infrastructure.Database.MyOpportunity.Entities.Lookups.MyOpportunityAction", "Action") + .WithMany() + .HasForeignKey("ActionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Yoma.Core.Infrastructure.Database.Opportunity.Entities.Opportunity", "Opportunity") + .WithMany() + .HasForeignKey("OpportunityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Yoma.Core.Infrastructure.Database.Entity.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Yoma.Core.Infrastructure.Database.MyOpportunity.Entities.Lookups.MyOpportunityVerificationStatus", "VerificationStatus") + .WithMany() + .HasForeignKey("VerificationStatusId"); + + b.Navigation("Action"); + + b.Navigation("Opportunity"); + + b.Navigation("User"); + + b.Navigation("VerificationStatus"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.MyOpportunity.Entities.MyOpportunityVerification", b => + { + b.HasOne("Yoma.Core.Infrastructure.Database.Core.Entities.BlobObject", "File") + .WithMany() + .HasForeignKey("FileId"); + + b.HasOne("Yoma.Core.Infrastructure.Database.MyOpportunity.Entities.MyOpportunity", "MyOpportunity") + .WithMany("Verifications") + .HasForeignKey("MyOpportunityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Yoma.Core.Infrastructure.Database.Opportunity.Entities.Lookups.OpportunityVerificationType", "VerificationType") + .WithMany() + .HasForeignKey("VerificationTypeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("File"); + + b.Navigation("MyOpportunity"); + + b.Navigation("VerificationType"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.Opportunity.Entities.Opportunity", b => + { + b.HasOne("Yoma.Core.Infrastructure.Database.Lookups.Entities.TimeInterval", "CommitmentInterval") + .WithMany() + .HasForeignKey("CommitmentIntervalId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Yoma.Core.Infrastructure.Database.Entity.Entities.User", "CreatedByUser") + .WithMany() + .HasForeignKey("CreatedByUserId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Yoma.Core.Infrastructure.Database.Opportunity.Entities.Lookups.OpportunityDifficulty", "Difficulty") + .WithMany() + .HasForeignKey("DifficultyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Yoma.Core.Infrastructure.Database.Lookups.Entities.EngagementType", "EngagementType") + .WithMany() + .HasForeignKey("EngagementTypeId"); + + b.HasOne("Yoma.Core.Infrastructure.Database.Entity.Entities.User", "ModifiedByUser") + .WithMany() + .HasForeignKey("ModifiedByUserId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Yoma.Core.Infrastructure.Database.Entity.Entities.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Yoma.Core.Infrastructure.Database.Opportunity.Entities.Lookups.OpportunityStatus", "Status") + .WithMany() + .HasForeignKey("StatusId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Yoma.Core.Infrastructure.Database.Opportunity.Entities.Lookups.OpportunityType", "Type") + .WithMany() + .HasForeignKey("TypeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CommitmentInterval"); + + b.Navigation("CreatedByUser"); + + b.Navigation("Difficulty"); + + b.Navigation("EngagementType"); + + b.Navigation("ModifiedByUser"); + + b.Navigation("Organization"); + + b.Navigation("Status"); + + b.Navigation("Type"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.Opportunity.Entities.OpportunityCategory", b => + { + b.HasOne("Yoma.Core.Infrastructure.Database.Opportunity.Entities.Lookups.OpportunityCategory", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Yoma.Core.Infrastructure.Database.Opportunity.Entities.Opportunity", "Opportunity") + .WithMany("Categories") + .HasForeignKey("OpportunityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Category"); + + b.Navigation("Opportunity"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.Opportunity.Entities.OpportunityCountry", b => + { + b.HasOne("Yoma.Core.Infrastructure.Database.Lookups.Entities.Country", "Country") + .WithMany() + .HasForeignKey("CountryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Yoma.Core.Infrastructure.Database.Opportunity.Entities.Opportunity", "Opportunity") + .WithMany("Countries") + .HasForeignKey("OpportunityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Country"); + + b.Navigation("Opportunity"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.Opportunity.Entities.OpportunityLanguage", b => + { + b.HasOne("Yoma.Core.Infrastructure.Database.Lookups.Entities.Language", "Language") + .WithMany() + .HasForeignKey("LanguageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Yoma.Core.Infrastructure.Database.Opportunity.Entities.Opportunity", "Opportunity") + .WithMany("Languages") + .HasForeignKey("OpportunityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Language"); + + b.Navigation("Opportunity"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.Opportunity.Entities.OpportunitySkill", b => + { + b.HasOne("Yoma.Core.Infrastructure.Database.Opportunity.Entities.Opportunity", "Opportunity") + .WithMany("Skills") + .HasForeignKey("OpportunityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Yoma.Core.Infrastructure.Database.Lookups.Entities.Skill", "Skill") + .WithMany() + .HasForeignKey("SkillId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Opportunity"); + + b.Navigation("Skill"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.Opportunity.Entities.OpportunityVerificationType", b => + { + b.HasOne("Yoma.Core.Infrastructure.Database.Opportunity.Entities.Opportunity", "Opportunity") + .WithMany("VerificationTypes") + .HasForeignKey("OpportunityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Yoma.Core.Infrastructure.Database.Opportunity.Entities.Lookups.OpportunityVerificationType", "VerificationType") + .WithMany() + .HasForeignKey("VerificationTypeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Opportunity"); + + b.Navigation("VerificationType"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.PartnerSharing.Entities.ProcessingLog", b => + { + b.HasOne("Yoma.Core.Infrastructure.Database.Opportunity.Entities.Opportunity", "Opportunity") + .WithMany() + .HasForeignKey("OpportunityId"); + + b.HasOne("Yoma.Core.Infrastructure.Database.PartnerSharing.Entities.Lookups.Partner", "Partner") + .WithMany() + .HasForeignKey("PartnerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Yoma.Core.Infrastructure.Database.PartnerSharing.Entities.Lookups.ProcessingStatus", "Status") + .WithMany() + .HasForeignKey("StatusId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Opportunity"); + + b.Navigation("Partner"); + + b.Navigation("Status"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.Reward.Entities.RewardTransaction", b => + { + b.HasOne("Yoma.Core.Infrastructure.Database.MyOpportunity.Entities.MyOpportunity", "MyOpportunity") + .WithMany() + .HasForeignKey("MyOpportunityId"); + + b.HasOne("Yoma.Core.Infrastructure.Database.Reward.Entities.Lookups.RewardTransactionStatus", "Status") + .WithMany() + .HasForeignKey("StatusId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Yoma.Core.Infrastructure.Database.Entity.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MyOpportunity"); + + b.Navigation("Status"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.Reward.Entities.WalletCreation", b => + { + b.HasOne("Yoma.Core.Infrastructure.Database.Reward.Entities.Lookups.WalletCreationStatus", "Status") + .WithMany() + .HasForeignKey("StatusId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Yoma.Core.Infrastructure.Database.Entity.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Status"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.SSI.Entities.Lookups.SSISchemaEntityProperty", b => + { + b.HasOne("Yoma.Core.Infrastructure.Database.SSI.Entities.Lookups.SSISchemaEntity", "SSISchemaEntity") + .WithMany("Properties") + .HasForeignKey("SSISchemaEntityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SSISchemaEntity"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.SSI.Entities.Lookups.SSISchemaEntityType", b => + { + b.HasOne("Yoma.Core.Infrastructure.Database.SSI.Entities.Lookups.SSISchemaEntity", "SSISchemaEntity") + .WithMany("Types") + .HasForeignKey("SSISchemaEntityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Yoma.Core.Infrastructure.Database.SSI.Entities.Lookups.SSISchemaType", "SSISchemaType") + .WithMany() + .HasForeignKey("SSISchemaTypeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SSISchemaEntity"); + + b.Navigation("SSISchemaType"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.SSI.Entities.SSICredentialIssuance", b => + { + b.HasOne("Yoma.Core.Infrastructure.Database.MyOpportunity.Entities.MyOpportunity", "MyOpportunity") + .WithMany() + .HasForeignKey("MyOpportunityId"); + + b.HasOne("Yoma.Core.Infrastructure.Database.Entity.Entities.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Yoma.Core.Infrastructure.Database.SSI.Entities.Lookups.SSISchemaType", "SchemaType") + .WithMany() + .HasForeignKey("SchemaTypeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Yoma.Core.Infrastructure.Database.SSI.Entities.Lookups.SSICredentialIssuanceStatus", "Status") + .WithMany() + .HasForeignKey("StatusId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Yoma.Core.Infrastructure.Database.Entity.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("MyOpportunity"); + + b.Navigation("Organization"); + + b.Navigation("SchemaType"); + + b.Navigation("Status"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.SSI.Entities.SSITenantCreation", b => + { + b.HasOne("Yoma.Core.Infrastructure.Database.Entity.Entities.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Yoma.Core.Infrastructure.Database.SSI.Entities.Lookups.SSITenantCreationStatus", "Status") + .WithMany() + .HasForeignKey("StatusId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Yoma.Core.Infrastructure.Database.Entity.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Status"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.Entity.Entities.Organization", b => + { + b.Navigation("Administrators"); + + b.Navigation("Documents"); + + b.Navigation("ProviderTypes"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.Entity.Entities.User", b => + { + b.Navigation("Skills"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.Entity.Entities.UserSkill", b => + { + b.Navigation("Organizations"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.Marketplace.Entities.StoreAccessControlRule", b => + { + b.Navigation("Opportunities"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.MyOpportunity.Entities.MyOpportunity", b => + { + b.Navigation("Verifications"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.Opportunity.Entities.Opportunity", b => + { + b.Navigation("Categories"); + + b.Navigation("Countries"); + + b.Navigation("Languages"); + + b.Navigation("Skills"); + + b.Navigation("VerificationTypes"); + }); + + modelBuilder.Entity("Yoma.Core.Infrastructure.Database.SSI.Entities.Lookups.SSISchemaEntity", b => + { + b.Navigation("Properties"); + + b.Navigation("Types"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/api/src/infrastructure/Yoma.Core.Infrastructure.Database/Migrations/20241023093106_ApplicationDb_Authentication_PhoneNumber.cs b/src/api/src/infrastructure/Yoma.Core.Infrastructure.Database/Migrations/20241023093106_ApplicationDb_Authentication_PhoneNumber.cs new file mode 100644 index 000000000..236951f85 --- /dev/null +++ b/src/api/src/infrastructure/Yoma.Core.Infrastructure.Database/Migrations/20241023093106_ApplicationDb_Authentication_PhoneNumber.cs @@ -0,0 +1,220 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Yoma.Core.Infrastructure.Database.Migrations +{ + /// + public partial class ApplicationDb_Authentication_PhoneNumber : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_User_FirstName_Surname_EmailConfirmed_PhoneNumber_DateOfBir~", + schema: "Entity", + table: "User"); + + migrationBuilder.AlterColumn( + name: "Name", + schema: "Reward", + table: "WalletCreationStatus", + type: "varchar(50)", + nullable: false, + oldClrType: typeof(string), + oldType: "varchar(20)"); + + migrationBuilder.AddColumn( + name: "Username", + schema: "Reward", + table: "WalletCreation", + type: "varchar(320)", + nullable: true); + + migrationBuilder.AlterColumn( + name: "Surname", + schema: "Entity", + table: "User", + type: "varchar(125)", + nullable: true, + oldClrType: typeof(string), + oldType: "varchar(125)"); + + migrationBuilder.AlterColumn( + name: "FirstName", + schema: "Entity", + table: "User", + type: "varchar(125)", + nullable: true, + oldClrType: typeof(string), + oldType: "varchar(125)"); + + migrationBuilder.AlterColumn( + name: "EmailConfirmed", + schema: "Entity", + table: "User", + type: "boolean", + nullable: true, + oldClrType: typeof(bool), + oldType: "boolean"); + + migrationBuilder.AlterColumn( + name: "Email", + schema: "Entity", + table: "User", + type: "varchar(320)", + nullable: true, + oldClrType: typeof(string), + oldType: "varchar(320)"); + + migrationBuilder.AlterColumn( + name: "DisplayName", + schema: "Entity", + table: "User", + type: "varchar(255)", + nullable: true, + oldClrType: typeof(string), + oldType: "varchar(255)"); + + migrationBuilder.AddColumn( + name: "PhoneNumberConfirmed", + schema: "Entity", + table: "User", + type: "boolean", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_WalletCreation_Username", + schema: "Reward", + table: "WalletCreation", + column: "Username", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_User_ExternalId", + schema: "Entity", + table: "User", + column: "ExternalId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_User_FirstName_Surname_DisplayName_EmailConfirmed_PhoneNumb~", + schema: "Entity", + table: "User", + columns: ["FirstName", "Surname", "DisplayName", "EmailConfirmed", "PhoneNumberConfirmed", "DateOfBirth", "DateLastLogin", "YoIDOnboarded", "DateYoIDOnboarded", "DateCreated", "DateModified"]); + + migrationBuilder.CreateIndex( + name: "IX_User_PhoneNumber", + schema: "Entity", + table: "User", + column: "PhoneNumber", + unique: true); + + ApplicationDb_Authentication_PhoneNumber_Seeding.Seed(migrationBuilder); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_WalletCreation_Username", + schema: "Reward", + table: "WalletCreation"); + + migrationBuilder.DropIndex( + name: "IX_User_ExternalId", + schema: "Entity", + table: "User"); + + migrationBuilder.DropIndex( + name: "IX_User_FirstName_Surname_DisplayName_EmailConfirmed_PhoneNumb~", + schema: "Entity", + table: "User"); + + migrationBuilder.DropIndex( + name: "IX_User_PhoneNumber", + schema: "Entity", + table: "User"); + + migrationBuilder.DropColumn( + name: "Username", + schema: "Reward", + table: "WalletCreation"); + + migrationBuilder.DropColumn( + name: "PhoneNumberConfirmed", + schema: "Entity", + table: "User"); + + migrationBuilder.AlterColumn( + name: "Name", + schema: "Reward", + table: "WalletCreationStatus", + type: "varchar(20)", + nullable: false, + oldClrType: typeof(string), + oldType: "varchar(50)"); + + migrationBuilder.AlterColumn( + name: "Surname", + schema: "Entity", + table: "User", + type: "varchar(125)", + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "varchar(125)", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "FirstName", + schema: "Entity", + table: "User", + type: "varchar(125)", + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "varchar(125)", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "EmailConfirmed", + schema: "Entity", + table: "User", + type: "boolean", + nullable: false, + defaultValue: false, + oldClrType: typeof(bool), + oldType: "boolean", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Email", + schema: "Entity", + table: "User", + type: "varchar(320)", + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "varchar(320)", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "DisplayName", + schema: "Entity", + table: "User", + type: "varchar(255)", + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "varchar(255)", + oldNullable: true); + + migrationBuilder.CreateIndex( + name: "IX_User_FirstName_Surname_EmailConfirmed_PhoneNumber_DateOfBir~", + schema: "Entity", + table: "User", + columns: ["FirstName", "Surname", "EmailConfirmed", "PhoneNumber", "DateOfBirth", "DateLastLogin", "ExternalId", "YoIDOnboarded", "DateYoIDOnboarded", "DateCreated", "DateModified"]); + } + } +} diff --git a/src/api/src/infrastructure/Yoma.Core.Infrastructure.Database/Migrations/20241023093106_ApplicationDb_Authentication_PhoneNumber_Seeding.cs b/src/api/src/infrastructure/Yoma.Core.Infrastructure.Database/Migrations/20241023093106_ApplicationDb_Authentication_PhoneNumber_Seeding.cs new file mode 100644 index 000000000..b527cf88b --- /dev/null +++ b/src/api/src/infrastructure/Yoma.Core.Infrastructure.Database/Migrations/20241023093106_ApplicationDb_Authentication_PhoneNumber_Seeding.cs @@ -0,0 +1,99 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +namespace Yoma.Core.Infrastructure.Database.Migrations +{ + internal class ApplicationDb_Authentication_PhoneNumber_Seeding + { + internal static void Seed(MigrationBuilder migrationBuilder) + { + #region Entity + #region Lookups + // Update specific key in SettingsDefinition + migrationBuilder.Sql( + "UPDATE \"Entity\".\"SettingsDefinition\" " + + "SET \"Key\" = 'User_Share_Contact_Info_With_Partners' " + + "WHERE \"Key\" = 'User_Share_Email_With_Partners';"); + + // Replace all occurrences of '_Email_' with '_Notification_' in the Key field + migrationBuilder.Sql( + "UPDATE \"Entity\".\"SettingsDefinition\" " + + "SET \"Key\" = REPLACE(\"Key\", '_Email_', '_Notification_') " + + "WHERE \"Key\" LIKE '%_Email_%';"); + + // Update Title and Description to replace 'email' with 'contact information' + migrationBuilder.Sql( + "UPDATE \"Entity\".\"SettingsDefinition\" " + + "SET \"Title\" = REPLACE(\"Title\", 'email', 'contact information'), " + + "\"Description\" = REPLACE(\"Description\", 'email', 'contact information') " + + "WHERE \"Key\" = 'User_Share_Contact_Info_With_Partners';"); + #endregion Lookups + #endregion Entity + + #region SSI + #region Lookups + migrationBuilder.UpdateData( + schema: "SSI", + table: "SchemaEntityProperty", + keyColumn: "Id", + keyValue: "32447353-1698-467C-8B5D-AD85E89235B0", + column: "Required", + value: false + ); + + migrationBuilder.UpdateData( + schema: "SSI", + table: "SchemaEntityProperty", + keyColumn: "Id", + keyValue: "D26B85E6-223E-48B6-A12F-6C2D0136DD2F", + column: "Required", + value: false + ); + + migrationBuilder.UpdateData( + schema: "SSI", + table: "SchemaEntityProperty", + keyColumn: "Id", + keyValue: "F7D89C98-0447-42DF-8A2D-A369B9FBAEBA", + column: "Required", + value: false + ); + #endregion Lookups + #endregion SSI + + #region User + // Replace specific key 'User_Share_Email_With_Partners' with 'User_Share_Contact_Info_With_Partners' in the Settings field + migrationBuilder.Sql( + "UPDATE \"Entity\".\"User\" " + + "SET \"Settings\" = REPLACE(\"Settings\", 'User_Share_Email_With_Partners', 'User_Share_Contact_Info_With_Partners') " + + "WHERE \"Settings\" LIKE '%User_Share_Email_With_Partners%';"); + + // Replace all occurrences of '_Email_' with '_Notification_' in the Settings field + migrationBuilder.Sql( + "UPDATE \"Entity\".\"User\" " + + "SET \"Settings\" = REPLACE(\"Settings\", '_Email_', '_Notification_') " + + "WHERE \"Settings\" LIKE '%_Email_%';"); + #endregion User + + #region Reward + // Update Username field in WalletCreation table based on User email and StatusId + migrationBuilder.Sql( + "UPDATE \"Reward\".\"WalletCreation\" wc " + + "SET \"Username\" = u.\"Email\" " + + "FROM \"Entity\".\"User\" u " + + "WHERE wc.\"UserId\" = u.\"Id\" " + + "AND wc.\"StatusId\" = (SELECT \"Id\" FROM \"Reward\".\"WalletCreationStatus\" WHERE \"Name\" = 'Created');"); + + #region Lookups + migrationBuilder.InsertData( + table: "WalletCreationStatus", + columns: ["Id", "Name", "DateCreated"], + values: new object[,] + { + {"3F7BE722-8994-4591-81A2-ACAA42905E2A","PendingUsernameUpdate",DateTimeOffset.UtcNow} + }, + schema: "Reward"); + #endregion + #endregion Reward + } + } +} diff --git a/src/api/src/infrastructure/Yoma.Core.Infrastructure.Database/Migrations/ApplicationDbContextModelSnapshot.cs b/src/api/src/infrastructure/Yoma.Core.Infrastructure.Database/Migrations/ApplicationDbContextModelSnapshot.cs index 058a00bda..f04fae59a 100644 --- a/src/api/src/infrastructure/Yoma.Core.Infrastructure.Database/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/api/src/infrastructure/Yoma.Core.Infrastructure.Database/Migrations/ApplicationDbContextModelSnapshot.cs @@ -526,24 +526,21 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("timestamp with time zone"); b.Property("DisplayName") - .IsRequired() .HasColumnType("varchar(255)"); b.Property("EducationId") .HasColumnType("uuid"); b.Property("Email") - .IsRequired() .HasColumnType("varchar(320)"); - b.Property("EmailConfirmed") + b.Property("EmailConfirmed") .HasColumnType("boolean"); b.Property("ExternalId") .HasColumnType("uuid"); b.Property("FirstName") - .IsRequired() .HasColumnType("varchar(125)"); b.Property("GenderId") @@ -552,6 +549,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("PhoneNumber") .HasColumnType("varchar(50)"); + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + b.Property("PhotoId") .HasColumnType("uuid"); @@ -559,7 +559,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("text"); b.Property("Surname") - .IsRequired() .HasColumnType("varchar(125)"); b.Property("YoIDOnboarded") @@ -574,11 +573,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("Email") .IsUnique(); + b.HasIndex("ExternalId") + .IsUnique(); + b.HasIndex("GenderId"); + b.HasIndex("PhoneNumber") + .IsUnique(); + b.HasIndex("PhotoId"); - b.HasIndex("FirstName", "Surname", "EmailConfirmed", "PhoneNumber", "DateOfBirth", "DateLastLogin", "ExternalId", "YoIDOnboarded", "DateYoIDOnboarded", "DateCreated", "DateModified"); + b.HasIndex("FirstName", "Surname", "DisplayName", "EmailConfirmed", "PhoneNumberConfirmed", "DateOfBirth", "DateLastLogin", "YoIDOnboarded", "DateYoIDOnboarded", "DateCreated", "DateModified"); b.ToTable("User", "Entity"); }); @@ -1671,7 +1676,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Name") .IsRequired() - .HasColumnType("varchar(20)"); + .HasColumnType("varchar(50)"); b.HasKey("Id"); @@ -1757,6 +1762,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("UserId") .HasColumnType("uuid"); + b.Property("Username") + .HasColumnType("varchar(320)"); + b.Property("WalletId") .HasColumnType("varchar(50)"); @@ -1765,6 +1773,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("UserId") .IsUnique(); + b.HasIndex("Username") + .IsUnique(); + b.HasIndex("StatusId", "DateCreated", "DateModified"); b.ToTable("WalletCreation", "Reward"); diff --git a/src/api/src/infrastructure/Yoma.Core.Infrastructure.Database/MyOpportunity/Repositories/MyOpportunityRepository.cs b/src/api/src/infrastructure/Yoma.Core.Infrastructure.Database/MyOpportunity/Repositories/MyOpportunityRepository.cs index 37385ca90..8f0c68104 100644 --- a/src/api/src/infrastructure/Yoma.Core.Infrastructure.Database/MyOpportunity/Repositories/MyOpportunityRepository.cs +++ b/src/api/src/infrastructure/Yoma.Core.Infrastructure.Database/MyOpportunity/Repositories/MyOpportunityRepository.cs @@ -26,9 +26,10 @@ public MyOpportunityRepository(ApplicationDbContext context) : base(context) { } return _context.MyOpportunity.Select(entity => new Domain.MyOpportunity.Models.MyOpportunity() { Id = entity.Id, + Username = entity.User.Email ?? entity.User.PhoneNumber ?? string.Empty, UserId = entity.UserId, UserEmail = entity.User.Email, - UserDisplayName = entity.User.DisplayName, + UserDisplayName = entity.User.DisplayName ?? entity.User.Email ?? entity.User.PhoneNumber ?? string.Empty, UserDateOfBirth = entity.User.DateOfBirth, UserGender = entity.User.Gender == null ? null : entity.User.Gender.Name, UserCountryId = entity.User.Country == null ? null : entity.User.CountryId, diff --git a/src/api/src/infrastructure/Yoma.Core.Infrastructure.Database/Reward/Entities/Lookups/WalletCreationStatus.cs b/src/api/src/infrastructure/Yoma.Core.Infrastructure.Database/Reward/Entities/Lookups/WalletCreationStatus.cs index 8ab34b4d9..18e7be4f0 100644 --- a/src/api/src/infrastructure/Yoma.Core.Infrastructure.Database/Reward/Entities/Lookups/WalletCreationStatus.cs +++ b/src/api/src/infrastructure/Yoma.Core.Infrastructure.Database/Reward/Entities/Lookups/WalletCreationStatus.cs @@ -10,7 +10,7 @@ namespace Yoma.Core.Infrastructure.Database.Reward.Entities.Lookups public class WalletCreationStatus : BaseEntity { [Required] - [Column(TypeName = "varchar(20)")] + [Column(TypeName = "varchar(50)")] public string Name { get; set; } [Required] diff --git a/src/api/src/infrastructure/Yoma.Core.Infrastructure.Database/Reward/Entities/WalletCreation.cs b/src/api/src/infrastructure/Yoma.Core.Infrastructure.Database/Reward/Entities/WalletCreation.cs index b63dd624d..dddd49dfb 100644 --- a/src/api/src/infrastructure/Yoma.Core.Infrastructure.Database/Reward/Entities/WalletCreation.cs +++ b/src/api/src/infrastructure/Yoma.Core.Infrastructure.Database/Reward/Entities/WalletCreation.cs @@ -9,6 +9,7 @@ namespace Yoma.Core.Infrastructure.Database.Reward.Entities { [Table("WalletCreation", Schema = "Reward")] [Index(nameof(UserId), IsUnique = true)] + [Index(nameof(Username), IsUnique = true)] [Index(nameof(StatusId), nameof(DateCreated), nameof(DateModified))] public class WalletCreation : BaseEntity { @@ -21,6 +22,9 @@ public class WalletCreation : BaseEntity public Guid UserId { get; set; } public User User { get; set; } + [Column(TypeName = "varchar(320)")] + public string? Username { get; set; } + [Column(TypeName = "varchar(50)")] public string? WalletId { get; set; } diff --git a/src/api/src/infrastructure/Yoma.Core.Infrastructure.Database/Reward/Repositories/WalletCreationRepository.cs b/src/api/src/infrastructure/Yoma.Core.Infrastructure.Database/Reward/Repositories/WalletCreationRepository.cs index e82360a5f..cd13b2cb3 100644 --- a/src/api/src/infrastructure/Yoma.Core.Infrastructure.Database/Reward/Repositories/WalletCreationRepository.cs +++ b/src/api/src/infrastructure/Yoma.Core.Infrastructure.Database/Reward/Repositories/WalletCreationRepository.cs @@ -21,6 +21,7 @@ public IQueryable Query() StatusId = entity.StatusId, Status = Enum.Parse(entity.Status.Name, true), UserId = entity.UserId, + Username = entity.Username, WalletId = entity.WalletId, Balance = entity.Balance, ErrorReason = entity.ErrorReason, @@ -40,6 +41,7 @@ public async Task Create(WalletCreation item) Id = item.Id, StatusId = item.StatusId, UserId = item.UserId, + Username = item.Username, WalletId = item.WalletId, Balance = item.Balance, ErrorReason = item.ErrorReason, @@ -62,6 +64,7 @@ public async Task Update(WalletCreation item) item.DateModified = DateTimeOffset.UtcNow; + entity.Username = item.Username; entity.WalletId = item.WalletId; entity.Balance = item.Balance; entity.StatusId = item.StatusId; diff --git a/src/api/src/infrastructure/Yoma.Core.Infrastructure.Database/ef.bat b/src/api/src/infrastructure/Yoma.Core.Infrastructure.Database/ef.bat index f9c997d6e..3df77d8a3 100644 --- a/src/api/src/infrastructure/Yoma.Core.Infrastructure.Database/ef.bat +++ b/src/api/src/infrastructure/Yoma.Core.Infrastructure.Database/ef.bat @@ -1,4 +1,4 @@ -@ECHO OFF +@ECHO OFF SET /p migration="Enter migration name: " dotnet ef migrations add ApplicationDb_%migration% -c Yoma.Core.Infrastructure.Database.Context.ApplicationDbContext -o Migrations diff --git a/src/api/src/infrastructure/Yoma.Core.Infrastructure.Keycloak/Client/KeycloakClient.cs b/src/api/src/infrastructure/Yoma.Core.Infrastructure.Keycloak/Client/KeycloakClient.cs index 9d7906705..4c8c2b6d4 100644 --- a/src/api/src/infrastructure/Yoma.Core.Infrastructure.Keycloak/Client/KeycloakClient.cs +++ b/src/api/src/infrastructure/Yoma.Core.Infrastructure.Keycloak/Client/KeycloakClient.cs @@ -102,19 +102,20 @@ public bool AuthenticateWebhook(HttpContext httpContext) return kcUser.ToUser(); } - public async Task UpdateUser(User user, bool resetPassword, bool sendVerifyEmail) + public async Task UpdateUser(User user, bool resetPassword, bool sendVerifyEmail, bool updatePhoneNumber) { using var userApi = FS.Keycloak.RestApiClient.ClientFactory.ApiClientFactory.Create(_httpClient); var request = new UserRepresentation { Id = user.Id.ToString(), - FirstName = user.FirstName, - LastName = user.LastName, + FirstName = user.FirstName ?? string.Empty, + LastName = user.LastName ?? string.Empty, Attributes = [], - Username = user.Email, - Email = user.Email, - EmailVerified = user.EmailVerified + Username = user.Username, + Email = user.Email ?? string.Empty, + EmailVerified = user.EmailVerified, + RequiredActions = [] }; if (!string.IsNullOrEmpty(user.Country)) @@ -129,21 +130,24 @@ public async Task UpdateUser(User user, bool resetPassword, bool sendVerifyEmail if (!string.IsNullOrEmpty(user.Gender)) request.Attributes.Add(CustomAttributes.Gender.ToDescription(), new List { { user.Gender } }); - if (!string.IsNullOrEmpty(user.PhoneNumber)) - request.Attributes.Add(CustomAttributes.PhoneNumber.ToDescription(), new List { { user.PhoneNumber } }); - try { - // update user details + // if updating the phone number, add the "UPDATE_PHONE_NUMBER" action + if (updatePhoneNumber) request.RequiredActions.Add("UPDATE_PHONE_NUMBER"); + + // if resetting the password and the user has no email, add "UPDATE_PASSWORD" as a non - email action + if (resetPassword && string.IsNullOrEmpty(request.Email)) request.RequiredActions.Add("UPDATE_PASSWORD"); + + // update user details in keycloak await userApi.PutUsersByUserIdAsync(_keycloakAuthenticationOptions.Realm, user.Id.ToString(), request); - // send verify email + // send verify email if required if (sendVerifyEmail) - await userApi.PutUsersSendVerifyEmailByUserIdAsync(_keycloakAuthenticationOptions.Realm, user.Id.ToString()); //admin initiated email (executeActions); same result as PutUsersExecuteActionsEmailByIdAsync["VERIFY_EMAIL"] + await userApi.PutUsersSendVerifyEmailByUserIdAsync(_keycloakAuthenticationOptions.Realm, user.Id.ToString()); // same result as PutUsersExecuteActionsEmailByIdAsync["VERIFY_EMAIL"] - // send reset password email - if (resetPassword) - await userApi.PutUsersExecuteActionsEmailByUserIdAsync(_keycloakAuthenticationOptions.Realm, user.Id.ToString(), requestBody: ["UPDATE_PASSWORD"]); //admin initiated email (executeActions) + // if resetting the password and the user has an email, trigger the password reset email action + if (resetPassword && !string.IsNullOrEmpty(request.Email)) + await userApi.PutUsersExecuteActionsEmailByUserIdAsync(_keycloakAuthenticationOptions.Realm, user.Id.ToString(), requestBody: ["UPDATE_PASSWORD"]); // admin initiated update password email action (executeActions) } catch (Exception ex) { diff --git a/src/api/src/infrastructure/Yoma.Core.Infrastructure.Keycloak/Enumerations.cs b/src/api/src/infrastructure/Yoma.Core.Infrastructure.Keycloak/Enumerations.cs index f613a2b54..69a59a893 100644 --- a/src/api/src/infrastructure/Yoma.Core.Infrastructure.Keycloak/Enumerations.cs +++ b/src/api/src/infrastructure/Yoma.Core.Infrastructure.Keycloak/Enumerations.cs @@ -15,7 +15,9 @@ public enum CustomAttributes [Description("dateOfBirth")] DateOfBirth, [Description("terms_and_conditions")] - TermsAndConditions + TermsAndConditions, + [Description("phoneNumberVerified")] + PhoneNumberVerified } public enum WebhookRequestEventType diff --git a/src/api/src/infrastructure/Yoma.Core.Infrastructure.Keycloak/Extensions/UserExtensions.cs b/src/api/src/infrastructure/Yoma.Core.Infrastructure.Keycloak/Extensions/UserExtensions.cs index 4a8d32a4b..c9c861afd 100644 --- a/src/api/src/infrastructure/Yoma.Core.Infrastructure.Keycloak/Extensions/UserExtensions.cs +++ b/src/api/src/infrastructure/Yoma.Core.Infrastructure.Keycloak/Extensions/UserExtensions.cs @@ -14,15 +14,18 @@ public static User ToUser(this UserRepresentation kcUser) { Id = Guid.Parse(kcUser.Id), Username = kcUser.Username, - Email = kcUser.Email.Trim(), - FirstName = kcUser.FirstName.Trim(), - LastName = kcUser.LastName.Trim(), + Email = kcUser.Email?.Trim(), + FirstName = kcUser.FirstName?.Trim(), + LastName = kcUser.LastName?.Trim(), Country = kcUser.Attributes.ContainsKey(CustomAttributes.Country.ToDescription()) ? kcUser.Attributes[CustomAttributes.Country.ToDescription()].FirstOrDefault()?.Trim() : null, DateOfBirth = kcUser.Attributes.ContainsKey(CustomAttributes.DateOfBirth.ToDescription()) ? kcUser.Attributes[CustomAttributes.DateOfBirth.ToDescription()].FirstOrDefault()?.Trim() : null, Education = kcUser.Attributes.ContainsKey(CustomAttributes.Education.ToDescription()) ? kcUser.Attributes[CustomAttributes.Education.ToDescription()].FirstOrDefault()?.Trim() : null, Gender = kcUser.Attributes.ContainsKey(CustomAttributes.Gender.ToDescription()) ? kcUser.Attributes[CustomAttributes.Gender.ToDescription()].FirstOrDefault()?.Trim() : null, PhoneNumber = kcUser.Attributes.ContainsKey(CustomAttributes.PhoneNumber.ToDescription()) ? kcUser.Attributes[CustomAttributes.PhoneNumber.ToDescription()].FirstOrDefault()?.Trim() : null, - EmailVerified = kcUser.EmailVerified.HasValue && kcUser.EmailVerified.Value + EmailVerified = kcUser.EmailVerified.HasValue && kcUser.EmailVerified.Value, + PhoneNumberVerified = kcUser.Attributes.ContainsKey(CustomAttributes.PhoneNumberVerified.ToDescription()) + ? bool.TryParse(kcUser.Attributes[CustomAttributes.PhoneNumberVerified.ToDescription()].FirstOrDefault(), out var phoneNumberVerified) + ? phoneNumberVerified : null : null }; return result; diff --git a/src/api/src/infrastructure/Yoma.Core.Infrastructure.SendGrid/Client/SendGridClient.cs b/src/api/src/infrastructure/Yoma.Core.Infrastructure.SendGrid/Client/SendGridClient.cs index 180383555..1c65998f9 100644 --- a/src/api/src/infrastructure/Yoma.Core.Infrastructure.SendGrid/Client/SendGridClient.cs +++ b/src/api/src/infrastructure/Yoma.Core.Infrastructure.SendGrid/Client/SendGridClient.cs @@ -8,9 +8,9 @@ using Yoma.Core.Domain.Core.Helpers; using Yoma.Core.Domain.Core.Interfaces; using Yoma.Core.Domain.Core.Models; -using Yoma.Core.Domain.EmailProvider; -using Yoma.Core.Domain.EmailProvider.Interfaces; -using Yoma.Core.Domain.EmailProvider.Models; +using Yoma.Core.Domain.Notification; +using Yoma.Core.Domain.Notification.Interfaces; +using Yoma.Core.Domain.Notification.Models; using Yoma.Core.Infrastructure.SendGrid.Models; namespace Yoma.Core.Infrastructure.SendGrid.Client @@ -41,14 +41,14 @@ public SendGridClient(ILogger logger, #endregion #region Public Members - public async Task Send(EmailType type, List recipients, T data) - where T : EmailBase + public async Task Send(NotificationType type, List recipients, T data) + where T : NotificationBase { await Send(type, [(recipients, data)]); } - public async Task Send(EmailType type, List<(List Recipients, T Data)> recipientDataGroups) - where T : EmailBase + public async Task Send(NotificationType type, List<(List Recipients, T Data)> recipientDataGroups) + where T : NotificationBase { if (!_appSettings.SendGridEnabledEnvironmentsAsEnum.HasFlag(_environmentProvider.Environment)) { @@ -97,8 +97,8 @@ public async Task Send(EmailType type, List<(List Recipients, #endregion #region Private Members - private static List ProcessRecipients(List<(List recipients, T data)> recipientDataGroups) - where T : EmailBase + private static List ProcessRecipients(List<(List recipients, T data)> recipientDataGroups) + where T : NotificationBase { var result = new List(); @@ -107,7 +107,7 @@ private static List ProcessRecipients(List<(List _logger; + private readonly AppSettings _appSettings; + private readonly IEnvironmentProvider _environmentProvider; + private readonly TwilioOpptions _options; + private readonly ITwilioRestClient _twilioClient; + #endregion + + #region Constructor + public TwilioClient(ILogger logger, + AppSettings appSettings, + IEnvironmentProvider environmentProvider, + TwilioOpptions options, + ITwilioRestClient twilioClient) + { + _logger = logger; + _appSettings = appSettings; + _environmentProvider = environmentProvider; + _options = options; + _twilioClient = twilioClient; + } + #endregion + + #region Public Members + public async Task Send(MessageType deliveryType, NotificationType notificationType, List recipients, T data) + where T : NotificationBase + { + await Send(deliveryType, notificationType, [(recipients, data)]); + } + + public async Task Send(MessageType deliveryType, NotificationType notificationType, List<(List Recipients, T Data)> recipientDataGroups) + where T : NotificationBase + { + if (!_appSettings.TwilioEnabledEnvironmentsAsEnum.HasFlag(_environmentProvider.Environment)) + { + _logger.LogInformation("Sending of {deliveryType} skipped for environment '{environment}'", deliveryType, _environmentProvider.Environment); + return; + } + + if (recipientDataGroups == null || recipientDataGroups.Count == 0) + throw new ArgumentNullException(nameof(recipientDataGroups)); + + // Messages will only be sent if the 'From' number is configured based on the delivery type : + // - For SMS: The 'From' number must be configured for SMS in _options.From + // - For WhatsApp: The 'From' number must be configured for WhatsApp in _options.From + if (_options.From == null || !_options.From.ContainsKey(deliveryType.ToString())) + { + _logger.LogInformation("Sending of {deliveryType} skipped: 'From' number not configured", deliveryType); + return; + } + + // Messages will only be sent if: + // - For SMS, the template exists in the resources file (SMSTemplates) + // - For WhatsApp, the template ID is configured in _options.Templates + var smsTemplate = string.Empty; + switch (deliveryType) + { + case MessageType.SMS: + smsTemplate = SMSTemplates.ResourceManager.GetString(notificationType.ToString()); + if (string.IsNullOrWhiteSpace(smsTemplate)) + { + _logger.LogInformation("Sending of {deliveryType} skipped: Template for notification type '{notificationType}' not configured", deliveryType, notificationType); + return; + } + break; + + case MessageType.WhatsApp: + if (_options.Templates == null || !_options.Templates.ContainsKey(notificationType.ToString())) + { + _logger.LogWarning("Sending of {deliveryType} skipped: Template for notification type '{notificationType}' not configured", deliveryType, notificationType); + return; + } + break; + + default: + throw new InvalidOperationException($"Unsupported delivery type: {deliveryType}"); + } + + var failedRecipients = new List(); + foreach (var (recipients, data) in recipientDataGroups) + { + if (recipients == null || recipients.Count == 0) + throw new ArgumentNullException(nameof(recipientDataGroups), "Contains null or empty recipient list"); + + if (data == null) + throw new ArgumentNullException(nameof(recipientDataGroups), "Contains null data"); + + //ensure environment suffix + data.SubjectSuffix = _environmentProvider.Environment == Domain.Core.Environment.Production + ? string.Empty + : $"{_environmentProvider.Environment.ToDescription()} - "; + + foreach (var recipient in recipients) + { + data.RecipientDisplayName = string.IsNullOrEmpty(recipient.DisplayName) ? recipient.Username : recipient.DisplayName; + + var messageOptions = deliveryType switch + { + MessageType.SMS => new CreateMessageOptions(new PhoneNumber(recipient.PhoneNumber)) + { + From = new PhoneNumber(_options.From[deliveryType.ToString()]), + Body = ParseSMSBody(smsTemplate, data.ContentVariables) + }, + + MessageType.WhatsApp => new CreateMessageOptions(new PhoneNumber($"whatsapp:{recipient.PhoneNumber}")) + { + From = new PhoneNumber(_options.From[deliveryType.ToString()]), + ContentSid = _options.Templates![notificationType.ToString()], + ContentVariables = JsonConvert.SerializeObject(data.ContentVariables) + }, + + _ => throw new InvalidOperationException($"Unsupported delivery type: {deliveryType}") + }; + + var response = await MessageResource.CreateAsync(messageOptions, _twilioClient); + if (!response.ErrorCode.HasValue) continue; + + var message = string.IsNullOrEmpty(response.ErrorMessage) ? "Unknown error" : response.ErrorMessage; + var detail = $"{recipient.PhoneNumber}: {message} ({response.ErrorCode.Value})"; + failedRecipients.Add(detail); + } + } + + if (failedRecipients.Count == 0) return; + var consolidatedErrorMessage = $"Failed to send {deliveryType}:{Environment.NewLine}{string.Join(Environment.NewLine, failedRecipients)}"; + throw new HttpClientException(System.Net.HttpStatusCode.InternalServerError, consolidatedErrorMessage); + } + #endregion + + #region Private Members + private static string ParseSMSBody(string template, Dictionary contentVariables) + { + return ContentVariableRegex().Replace(template, match => + { + var key = match.Groups[1].Value; + return contentVariables.TryGetValue(key, out var value) ? value : match.Value; + }); + } + + [GeneratedRegex(@"\{(\d+)\}")] + private static partial Regex ContentVariableRegex(); + #endregion + } +} diff --git a/src/api/src/infrastructure/Yoma.Core.Infrastructure.Twillio/Client/TwilioClientFactory.cs b/src/api/src/infrastructure/Yoma.Core.Infrastructure.Twillio/Client/TwilioClientFactory.cs new file mode 100644 index 000000000..580deaf71 --- /dev/null +++ b/src/api/src/infrastructure/Yoma.Core.Infrastructure.Twillio/Client/TwilioClientFactory.cs @@ -0,0 +1,43 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Twilio.Clients; +using Yoma.Core.Domain.Core.Interfaces; +using Yoma.Core.Domain.Core.Models; +using Yoma.Core.Domain.Notification.Interfaces; +using Yoma.Core.Infrastructure.Twilio.Models; + +namespace Yoma.Core.Infrastructure.Twilio.Client +{ + public class TwilioClientFactory : IMessageProviderClientFactory + { + #region Class Variables + private readonly ILogger _logger; + private readonly AppSettings _appSettings; + private readonly IEnvironmentProvider _environmentProvider; + private readonly TwilioOpptions _options; + private readonly ITwilioRestClient _twilioClient; + #endregion + + #region Constructor + public TwilioClientFactory(ILogger logger, + IOptions appSettings, + IEnvironmentProvider environmentProvider, + IOptions options, + ITwilioRestClient twilioClient) + { + _logger = logger; + _appSettings = appSettings.Value; + _environmentProvider = environmentProvider; + _options = options.Value; + _twilioClient = twilioClient; + } + #endregion + + #region Public Members + public IMessageProviderClient CreateClient() + { + return new TwilioClient(_logger, _appSettings, _environmentProvider, _options, _twilioClient); + } + #endregion + } +} diff --git a/src/api/src/infrastructure/Yoma.Core.Infrastructure.Twillio/Models/TwilioOpptions.cs b/src/api/src/infrastructure/Yoma.Core.Infrastructure.Twillio/Models/TwilioOpptions.cs new file mode 100644 index 000000000..3baa7995d --- /dev/null +++ b/src/api/src/infrastructure/Yoma.Core.Infrastructure.Twillio/Models/TwilioOpptions.cs @@ -0,0 +1,15 @@ +namespace Yoma.Core.Infrastructure.Twilio.Models +{ + public class TwilioOpptions + { + public const string Section = "Twilio"; + + public string AccountSid { get; set; } + + public string AuthToken { get; set; } + + public Dictionary? From { get; set; } + + public Dictionary? Templates { get; set; } + } +} diff --git a/src/api/src/infrastructure/Yoma.Core.Infrastructure.Twillio/SMSTemplates.Designer.cs b/src/api/src/infrastructure/Yoma.Core.Infrastructure.Twillio/SMSTemplates.Designer.cs new file mode 100644 index 000000000..076792c3c --- /dev/null +++ b/src/api/src/infrastructure/Yoma.Core.Infrastructure.Twillio/SMSTemplates.Designer.cs @@ -0,0 +1,63 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace Yoma.Core.Infrastructure.Twilio { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class SMSTemplates { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal SMSTemplates() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Yoma.Core.Infrastructure.Twilio.SMSTemplates", typeof(SMSTemplates).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + } +} diff --git a/src/api/src/infrastructure/Yoma.Core.Infrastructure.Twillio/SMSTemplates.resx b/src/api/src/infrastructure/Yoma.Core.Infrastructure.Twillio/SMSTemplates.resx new file mode 100644 index 000000000..1af7de150 --- /dev/null +++ b/src/api/src/infrastructure/Yoma.Core.Infrastructure.Twillio/SMSTemplates.resx @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + \ No newline at end of file diff --git a/src/api/src/infrastructure/Yoma.Core.Infrastructure.Twillio/Startup.cs b/src/api/src/infrastructure/Yoma.Core.Infrastructure.Twillio/Startup.cs new file mode 100644 index 000000000..a9c50402a --- /dev/null +++ b/src/api/src/infrastructure/Yoma.Core.Infrastructure.Twillio/Startup.cs @@ -0,0 +1,30 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Twilio.Clients; +using Yoma.Core.Domain.Notification.Interfaces; +using Yoma.Core.Infrastructure.Twilio.Client; +using Yoma.Core.Infrastructure.Twilio.Models; + +namespace Yoma.Core.Infrastructure.Twilio +{ + public static class Startup + { + public static void ConfigureServices_MessageProvider(this IServiceCollection services, IConfiguration configuration) + { + services.Configure(options => configuration.GetSection(TwilioOpptions.Section).Bind(options)); + } + + public static void ConfigureServices_InfrastructureMessageProvider(this IServiceCollection services, IConfiguration configuration) + { + var options = configuration.GetSection(TwilioOpptions.Section).Get() + ?? throw new InvalidOperationException($"Failed to retrieve configuration section '{TwilioOpptions.Section}'"); + + services.AddSingleton(sp => + { + return new TwilioRestClient(options.AccountSid, options.AuthToken); + }); + + services.AddScoped(); + } + } +} diff --git a/src/api/src/infrastructure/Yoma.Core.Infrastructure.Twillio/Yoma.Core.Infrastructure.Twilio.csproj b/src/api/src/infrastructure/Yoma.Core.Infrastructure.Twillio/Yoma.Core.Infrastructure.Twilio.csproj new file mode 100644 index 000000000..97ce5d7f0 --- /dev/null +++ b/src/api/src/infrastructure/Yoma.Core.Infrastructure.Twillio/Yoma.Core.Infrastructure.Twilio.csproj @@ -0,0 +1,33 @@ + + + + net8.0 + enable + enable + en + + + + + + + + + + + + + True + True + SMSTemplates.resx + + + + + + ResXFileCodeGenerator + SMSTemplates.Designer.cs + + + + diff --git a/src/api/src/infrastructure/Yoma.Core.Infrastructure.Zlto/Client/ZltoClient.cs b/src/api/src/infrastructure/Yoma.Core.Infrastructure.Zlto/Client/ZltoClient.cs index 6c02a0813..2be44bf9a 100644 --- a/src/api/src/infrastructure/Yoma.Core.Infrastructure.Zlto/Client/ZltoClient.cs +++ b/src/api/src/infrastructure/Yoma.Core.Infrastructure.Zlto/Client/ZltoClient.cs @@ -77,6 +77,34 @@ public ZltoClient(AppSettings appSettings, ZltoOptions options, return (result, status); } + public async Task UpdateWalletUsername(string usernameCurrent, string username) + { + ArgumentException.ThrowIfNullOrWhiteSpace(usernameCurrent, nameof(usernameCurrent)); + usernameCurrent = usernameCurrent.Trim(); + + ArgumentException.ThrowIfNullOrWhiteSpace(username, nameof(username)); + username = username.Trim(); + + if (string.Equals(usernameCurrent, username, StringComparison.InvariantCultureIgnoreCase)) + return username; + + var request = new WalletRequestUpdateUsername + { + UsernameCurrent = usernameCurrent, + Username = username + }; + + var response = await _options.Wallet.BaseUrl + .AppendPathSegment("update_external_account_username") + .AppendPathSegment(usernameCurrent) + .WithAuthHeaders(await GetAuthHeaders()) + .PutJsonAsync(request) + .EnsureSuccessStatusCodeAsync() + .ReceiveJson(); + + return response.WalletId; + } + public async Task GetWallet(string walletId) { if (string.IsNullOrWhiteSpace(walletId)) @@ -155,7 +183,7 @@ public async Task RewardEarn(RewardAwardRequest request) TaskExternalId = request.Id.ToString(), TaskProgramId = "n/a", BankTransactionId = "n/a", - UserName = request.Username, + Username = request.Username, TaskSkills = (request.Skills != null && request.Skills.Count != 0) ? string.Join(",", request.Skills.Select(o => o.Name)) : "n/a", TaskCountry = (request.Countries != null && request.Countries.Count != 0) ? string.Join(",", request.Countries.Select(o => o.Name)) : "n/a", TaskLanguage = (request.Languages != null && request.Languages.Count != 0) ? string.Join(",", request.Languages.Select(o => o.Name)) : "n/a", @@ -457,7 +485,7 @@ private async Task CreateAccount(Domain.Reward.Models.Provide { OwnerOrigin = _accessToken.PartnerName, OwnerName = request.DisplayName, - UserName = request.Username, + Username = request.Username, Balance = (int)request.Balance //OwnerId: system assigned; can not be specified //UserPassword: used with external wallet activation; with Yoma wallets are internal @@ -558,6 +586,8 @@ private async Task ListStoresInternal(string countryCodeAlp throw new ArgumentNullException(nameof(countryCodeAlpha2)); countryCodeAlpha2 = countryCodeAlpha2.Trim(); + categoryId = categoryId?.Trim(); + // attempt to find the country owner for the specified country code (countryCodeAlpha2) // if the country is not explicitly configured, default to the owner configured for the Worldwide (WW) store var countryOwner = _options.Store.Owners.SingleOrDefault(o => string.Equals(o.CountryCodeAlpha2, countryCodeAlpha2, StringComparison.InvariantCultureIgnoreCase)); @@ -570,20 +600,47 @@ private async Task ListStoresInternal(string countryCodeAlp query = query.SetQueryParam("country_owner_id", countryOwnerId); var effectiveLimit = limit.HasValue && limit.Value > default(int) ? limit.Value : Limit_Default; - query = query.SetQueryParam("limit", effectiveLimit); - if (offset.HasValue && offset.Value >= default(int)) - query = query.SetQueryParam("offset", offset); + StoreResponseSearch? responseSearch = null; + if (string.IsNullOrEmpty(categoryId)) + { + query = query.SetQueryParam("limit", effectiveLimit); - var response = await query.PostAsync() - .EnsureSuccessStatusCodeAsync() - .ReceiveJson(); + if (offset.HasValue && offset.Value >= default(int)) + query = query.SetQueryParam("offset", offset); - categoryId = categoryId?.Trim(); - if (!string.IsNullOrEmpty(categoryId)) - response.Items = response.Items.Where(o => string.Equals(o.Category.Id, categoryId, StringComparison.InvariantCultureIgnoreCase)).ToList(); + responseSearch = await query.PostAsync() + .EnsureSuccessStatusCodeAsync() + .ReceiveJson(); + + return responseSearch; + } + + int offsetCurrent = 0; + var resultSearch = new StoreResponseSearch { Items = [] }; + + query = query.SetQueryParam("limit", Limit_Default); + do + { + query = query.SetQueryParam("offset", offsetCurrent); + + responseSearch = await query.PostAsync() + .EnsureSuccessStatusCodeAsync() + .ReceiveJson(); + + if (responseSearch?.Items == null || responseSearch.Items.Count == 0) + break; + + resultSearch.Items.AddRange(responseSearch.Items); + offsetCurrent += Limit_Default; + } + while (responseSearch.Items.Count == Limit_Default); + + resultSearch.Items = resultSearch.Items.Where(o => string.Equals(o.Category.Id, categoryId, StringComparison.InvariantCultureIgnoreCase)) + .Skip(offset ?? default).Take(effectiveLimit) + .ToList(); - return response; + return resultSearch; } #endregion } diff --git a/src/api/src/infrastructure/Yoma.Core.Infrastructure.Zlto/Models/Reward.cs b/src/api/src/infrastructure/Yoma.Core.Infrastructure.Zlto/Models/Reward.cs index af984fdb3..f0a69612d 100644 --- a/src/api/src/infrastructure/Yoma.Core.Infrastructure.Zlto/Models/Reward.cs +++ b/src/api/src/infrastructure/Yoma.Core.Infrastructure.Zlto/Models/Reward.cs @@ -38,7 +38,7 @@ public class RewardEarnRequest public string ZltoWalletId { get; set; } [JsonProperty("user_name")] - public string UserName { get; set; } + public string Username { get; set; } [JsonProperty("task_zlto_reward")] public int TaskZltoReward { get; set; } @@ -128,7 +128,7 @@ public class RewardEarnTask public string TaskTitle { get; set; } [JsonProperty("user_name")] - public string UserName { get; set; } + public string Username { get; set; } [JsonProperty("task_external_proof")] public string TaskExternalProof { get; set; } diff --git a/src/api/src/infrastructure/Yoma.Core.Infrastructure.Zlto/Models/Wallet.cs b/src/api/src/infrastructure/Yoma.Core.Infrastructure.Zlto/Models/Wallet.cs index b5b18a589..b263efd40 100644 --- a/src/api/src/infrastructure/Yoma.Core.Infrastructure.Zlto/Models/Wallet.cs +++ b/src/api/src/infrastructure/Yoma.Core.Infrastructure.Zlto/Models/Wallet.cs @@ -5,6 +5,15 @@ namespace Yoma.Core.Infrastructure.Zlto.Models { + public class WalletRequestUpdateUsername + { + [JsonProperty("user_name")] + public string UsernameCurrent { get; set; } + + [JsonProperty("new_user_name")] + public string Username { get; set; } + } + public class WalletRequestCreate { [JsonProperty("owner_id")] @@ -17,7 +26,7 @@ public class WalletRequestCreate public string OwnerName { get; set; } [JsonProperty("user_name")] - public string UserName { get; set; } + public string Username { get; set; } [JsonProperty("user_password")] public string UserPassword { get; set; } @@ -57,7 +66,7 @@ public class WalletAccountInfo public string OwnerOrigin { get; set; } [JsonProperty("user_name")] - public string UserName { get; set; } + public string Username { get; set; } [JsonProperty("last_updated")] public DateTime LastUpdated { get; set; } diff --git a/src/keycloak/providers/keycloak-phone-provider.resources/target/classes/META-INF/keycloak-themes.json b/src/keycloak-providers/keycloak-phone-provider.resources/target/classes/META-INF/keycloak-themes.json similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider.resources/target/classes/META-INF/keycloak-themes.json rename to src/keycloak-providers/keycloak-phone-provider.resources/target/classes/META-INF/keycloak-themes.json diff --git a/src/keycloak/providers/keycloak-phone-provider.resources/target/classes/theme/phone/account/account.ftl b/src/keycloak-providers/keycloak-phone-provider.resources/target/classes/theme/phone/account/account.ftl similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider.resources/target/classes/theme/phone/account/account.ftl rename to src/keycloak-providers/keycloak-phone-provider.resources/target/classes/theme/phone/account/account.ftl diff --git a/src/keycloak/providers/keycloak-phone-provider.resources/target/classes/theme/phone/account/theme.properties b/src/keycloak-providers/keycloak-phone-provider.resources/target/classes/theme/phone/account/theme.properties similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider.resources/target/classes/theme/phone/account/theme.properties rename to src/keycloak-providers/keycloak-phone-provider.resources/target/classes/theme/phone/account/theme.properties diff --git a/src/keycloak/providers/keycloak-phone-provider.resources/target/classes/theme/phone/email/html/email-update-confirmation.ftl b/src/keycloak-providers/keycloak-phone-provider.resources/target/classes/theme/phone/email/html/email-update-confirmation.ftl similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider.resources/target/classes/theme/phone/email/html/email-update-confirmation.ftl rename to src/keycloak-providers/keycloak-phone-provider.resources/target/classes/theme/phone/email/html/email-update-confirmation.ftl diff --git a/src/keycloak/providers/keycloak-phone-provider.resources/target/classes/theme/phone/email/html/email-verification.ftl b/src/keycloak-providers/keycloak-phone-provider.resources/target/classes/theme/phone/email/html/email-verification.ftl similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider.resources/target/classes/theme/phone/email/html/email-verification.ftl rename to src/keycloak-providers/keycloak-phone-provider.resources/target/classes/theme/phone/email/html/email-verification.ftl diff --git a/src/keycloak/providers/keycloak-phone-provider.resources/target/classes/theme/phone/email/html/otp.ftl b/src/keycloak-providers/keycloak-phone-provider.resources/target/classes/theme/phone/email/html/otp.ftl similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider.resources/target/classes/theme/phone/email/html/otp.ftl rename to src/keycloak-providers/keycloak-phone-provider.resources/target/classes/theme/phone/email/html/otp.ftl diff --git a/src/keycloak/providers/keycloak-phone-provider.resources/target/classes/theme/phone/email/html/password-reset.ftl b/src/keycloak-providers/keycloak-phone-provider.resources/target/classes/theme/phone/email/html/password-reset.ftl similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider.resources/target/classes/theme/phone/email/html/password-reset.ftl rename to src/keycloak-providers/keycloak-phone-provider.resources/target/classes/theme/phone/email/html/password-reset.ftl diff --git a/src/keycloak/providers/keycloak-phone-provider.resources/target/classes/theme/phone/email/html/template.ftl b/src/keycloak-providers/keycloak-phone-provider.resources/target/classes/theme/phone/email/html/template.ftl similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider.resources/target/classes/theme/phone/email/html/template.ftl rename to src/keycloak-providers/keycloak-phone-provider.resources/target/classes/theme/phone/email/html/template.ftl diff --git a/src/keycloak/providers/keycloak-phone-provider.resources/target/classes/theme/phone/email/messages/messages_en.properties b/src/keycloak-providers/keycloak-phone-provider.resources/target/classes/theme/phone/email/messages/messages_en.properties similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider.resources/target/classes/theme/phone/email/messages/messages_en.properties rename to src/keycloak-providers/keycloak-phone-provider.resources/target/classes/theme/phone/email/messages/messages_en.properties diff --git a/src/keycloak/providers/keycloak-phone-provider.resources/target/classes/theme/phone/email/messages/messages_es.properties b/src/keycloak-providers/keycloak-phone-provider.resources/target/classes/theme/phone/email/messages/messages_es.properties similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider.resources/target/classes/theme/phone/email/messages/messages_es.properties rename to src/keycloak-providers/keycloak-phone-provider.resources/target/classes/theme/phone/email/messages/messages_es.properties diff --git a/src/keycloak/providers/keycloak-phone-provider.resources/target/classes/theme/phone/email/messages/messages_fr.properties b/src/keycloak-providers/keycloak-phone-provider.resources/target/classes/theme/phone/email/messages/messages_fr.properties similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider.resources/target/classes/theme/phone/email/messages/messages_fr.properties rename to src/keycloak-providers/keycloak-phone-provider.resources/target/classes/theme/phone/email/messages/messages_fr.properties diff --git a/src/keycloak/providers/keycloak-phone-provider.resources/target/classes/theme/phone/email/resources/img/icon-zlto.svg b/src/keycloak-providers/keycloak-phone-provider.resources/target/classes/theme/phone/email/resources/img/icon-zlto.svg similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider.resources/target/classes/theme/phone/email/resources/img/icon-zlto.svg rename to src/keycloak-providers/keycloak-phone-provider.resources/target/classes/theme/phone/email/resources/img/icon-zlto.svg diff --git a/src/keycloak/providers/keycloak-phone-provider.resources/target/classes/theme/phone/email/resources/img/yoma.png b/src/keycloak-providers/keycloak-phone-provider.resources/target/classes/theme/phone/email/resources/img/yoma.png similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider.resources/target/classes/theme/phone/email/resources/img/yoma.png rename to src/keycloak-providers/keycloak-phone-provider.resources/target/classes/theme/phone/email/resources/img/yoma.png diff --git a/src/keycloak/providers/keycloak-phone-provider.resources/target/classes/theme/phone/email/text/email-update-confirmation.ftl b/src/keycloak-providers/keycloak-phone-provider.resources/target/classes/theme/phone/email/text/email-update-confirmation.ftl similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider.resources/target/classes/theme/phone/email/text/email-update-confirmation.ftl rename to src/keycloak-providers/keycloak-phone-provider.resources/target/classes/theme/phone/email/text/email-update-confirmation.ftl diff --git a/src/keycloak/providers/keycloak-phone-provider.resources/target/classes/theme/phone/email/text/email-verification.ftl b/src/keycloak-providers/keycloak-phone-provider.resources/target/classes/theme/phone/email/text/email-verification.ftl similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider.resources/target/classes/theme/phone/email/text/email-verification.ftl rename to src/keycloak-providers/keycloak-phone-provider.resources/target/classes/theme/phone/email/text/email-verification.ftl diff --git a/src/keycloak/providers/keycloak-phone-provider.resources/target/classes/theme/phone/email/text/password-reset.ftl b/src/keycloak-providers/keycloak-phone-provider.resources/target/classes/theme/phone/email/text/password-reset.ftl similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider.resources/target/classes/theme/phone/email/text/password-reset.ftl rename to src/keycloak-providers/keycloak-phone-provider.resources/target/classes/theme/phone/email/text/password-reset.ftl diff --git a/src/keycloak/providers/keycloak-phone-provider.resources/target/classes/theme/phone/email/theme.properties b/src/keycloak-providers/keycloak-phone-provider.resources/target/classes/theme/phone/email/theme.properties similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider.resources/target/classes/theme/phone/email/theme.properties rename to src/keycloak-providers/keycloak-phone-provider.resources/target/classes/theme/phone/email/theme.properties diff --git a/src/keycloak/providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/login-reset-password.ftl b/src/keycloak-providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/login-reset-password.ftl similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/login-reset-password.ftl rename to src/keycloak-providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/login-reset-password.ftl diff --git a/src/keycloak/providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/login-sms-otp-config.ftl b/src/keycloak-providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/login-sms-otp-config.ftl similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/login-sms-otp-config.ftl rename to src/keycloak-providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/login-sms-otp-config.ftl diff --git a/src/keycloak/providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/login-sms-otp.ftl b/src/keycloak-providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/login-sms-otp.ftl similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/login-sms-otp.ftl rename to src/keycloak-providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/login-sms-otp.ftl diff --git a/src/keycloak/providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/login-update-phone-number.ftl b/src/keycloak-providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/login-update-phone-number.ftl similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/login-update-phone-number.ftl rename to src/keycloak-providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/login-update-phone-number.ftl diff --git a/src/keycloak/providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/login.ftl b/src/keycloak-providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/login.ftl similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/login.ftl rename to src/keycloak-providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/login.ftl diff --git a/src/keycloak/providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/messages/messages_en.properties b/src/keycloak-providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/messages/messages_en.properties similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/messages/messages_en.properties rename to src/keycloak-providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/messages/messages_en.properties diff --git a/src/keycloak/providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/messages/messages_es.properties b/src/keycloak-providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/messages/messages_es.properties similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/messages/messages_es.properties rename to src/keycloak-providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/messages/messages_es.properties diff --git a/src/keycloak/providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/messages/messages_fr.properties b/src/keycloak-providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/messages/messages_fr.properties similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/messages/messages_fr.properties rename to src/keycloak-providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/messages/messages_fr.properties diff --git a/src/keycloak/providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/register-user-profile.ftl b/src/keycloak-providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/register-user-profile.ftl similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/register-user-profile.ftl rename to src/keycloak-providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/register-user-profile.ftl diff --git a/src/keycloak/providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/resources/css/passwordIndicator.css b/src/keycloak-providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/resources/css/passwordIndicator.css similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/resources/css/passwordIndicator.css rename to src/keycloak-providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/resources/css/passwordIndicator.css diff --git a/src/keycloak/providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/resources/css/styles.css b/src/keycloak-providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/resources/css/styles.css similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/resources/css/styles.css rename to src/keycloak-providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/resources/css/styles.css diff --git a/src/keycloak/providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/resources/img/email-icon.png b/src/keycloak-providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/resources/img/email-icon.png similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/resources/img/email-icon.png rename to src/keycloak-providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/resources/img/email-icon.png diff --git a/src/keycloak/providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/resources/img/favicon.ico b/src/keycloak-providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/resources/img/favicon.ico similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/resources/img/favicon.ico rename to src/keycloak-providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/resources/img/favicon.ico diff --git a/src/keycloak/providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/resources/img/icon-check.png b/src/keycloak-providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/resources/img/icon-check.png similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/resources/img/icon-check.png rename to src/keycloak-providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/resources/img/icon-check.png diff --git a/src/keycloak/providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/resources/img/icon-cross.png b/src/keycloak-providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/resources/img/icon-cross.png similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/resources/img/icon-cross.png rename to src/keycloak-providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/resources/img/icon-cross.png diff --git a/src/keycloak/providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/resources/img/logo.svg b/src/keycloak-providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/resources/img/logo.svg similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/resources/img/logo.svg rename to src/keycloak-providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/resources/img/logo.svg diff --git a/src/keycloak/providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/resources/img/yoma.png b/src/keycloak-providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/resources/img/yoma.png similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/resources/img/yoma.png rename to src/keycloak-providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/resources/img/yoma.png diff --git a/src/keycloak/providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/resources/js/passwordIndicator.js b/src/keycloak-providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/resources/js/passwordIndicator.js similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/resources/js/passwordIndicator.js rename to src/keycloak-providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/resources/js/passwordIndicator.js diff --git a/src/keycloak/providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/theme.properties b/src/keycloak-providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/theme.properties similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/theme.properties rename to src/keycloak-providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/theme.properties diff --git a/src/keycloak/providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/user-profile-commons.ftl b/src/keycloak-providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/user-profile-commons.ftl similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/user-profile-commons.ftl rename to src/keycloak-providers/keycloak-phone-provider.resources/target/classes/theme/phone/login/user-profile-commons.ftl diff --git a/src/keycloak/providers/keycloak-phone-provider.resources/target/keycloak-phone-provider.resources-2.3.4-snapshot.jar b/src/keycloak-providers/keycloak-phone-provider.resources/target/keycloak-phone-provider.resources-2.3.4-snapshot.jar similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider.resources/target/keycloak-phone-provider.resources-2.3.4-snapshot.jar rename to src/keycloak-providers/keycloak-phone-provider.resources/target/keycloak-phone-provider.resources-2.3.4-snapshot.jar diff --git a/src/keycloak/providers/keycloak-phone-provider.resources/target/maven-archiver/pom.properties b/src/keycloak-providers/keycloak-phone-provider.resources/target/maven-archiver/pom.properties similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider.resources/target/maven-archiver/pom.properties rename to src/keycloak-providers/keycloak-phone-provider.resources/target/maven-archiver/pom.properties diff --git a/src/keycloak/providers/keycloak-phone-provider/target/archive-tmp/keycloak-phone-provider-2.3.4-snapshot.jar b/src/keycloak-providers/keycloak-phone-provider/target/archive-tmp/keycloak-phone-provider-2.3.4-snapshot.jar similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider/target/archive-tmp/keycloak-phone-provider-2.3.4-snapshot.jar rename to src/keycloak-providers/keycloak-phone-provider/target/archive-tmp/keycloak-phone-provider-2.3.4-snapshot.jar diff --git a/src/keycloak/providers/keycloak-phone-provider/target/classes/META-INF/changelog/token-code-changelog.xml b/src/keycloak-providers/keycloak-phone-provider/target/classes/META-INF/changelog/token-code-changelog.xml similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider/target/classes/META-INF/changelog/token-code-changelog.xml rename to src/keycloak-providers/keycloak-phone-provider/target/classes/META-INF/changelog/token-code-changelog.xml diff --git a/src/keycloak/providers/keycloak-phone-provider/target/classes/META-INF/keycloak-themes.json b/src/keycloak-providers/keycloak-phone-provider/target/classes/META-INF/keycloak-themes.json similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider/target/classes/META-INF/keycloak-themes.json rename to src/keycloak-providers/keycloak-phone-provider/target/classes/META-INF/keycloak-themes.json diff --git a/src/keycloak/providers/keycloak-phone-provider/target/classes/META-INF/services/cc.coopersoft.keycloak.phone.providers.spi.PhoneProviderFactory b/src/keycloak-providers/keycloak-phone-provider/target/classes/META-INF/services/cc.coopersoft.keycloak.phone.providers.spi.PhoneProviderFactory similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider/target/classes/META-INF/services/cc.coopersoft.keycloak.phone.providers.spi.PhoneProviderFactory rename to src/keycloak-providers/keycloak-phone-provider/target/classes/META-INF/services/cc.coopersoft.keycloak.phone.providers.spi.PhoneProviderFactory diff --git a/src/keycloak/providers/keycloak-phone-provider/target/classes/META-INF/services/cc.coopersoft.keycloak.phone.providers.spi.PhoneVerificationCodeProviderFactory b/src/keycloak-providers/keycloak-phone-provider/target/classes/META-INF/services/cc.coopersoft.keycloak.phone.providers.spi.PhoneVerificationCodeProviderFactory similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider/target/classes/META-INF/services/cc.coopersoft.keycloak.phone.providers.spi.PhoneVerificationCodeProviderFactory rename to src/keycloak-providers/keycloak-phone-provider/target/classes/META-INF/services/cc.coopersoft.keycloak.phone.providers.spi.PhoneVerificationCodeProviderFactory diff --git a/src/keycloak/providers/keycloak-phone-provider/target/classes/META-INF/services/org.keycloak.authentication.AuthenticatorFactory b/src/keycloak-providers/keycloak-phone-provider/target/classes/META-INF/services/org.keycloak.authentication.AuthenticatorFactory similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider/target/classes/META-INF/services/org.keycloak.authentication.AuthenticatorFactory rename to src/keycloak-providers/keycloak-phone-provider/target/classes/META-INF/services/org.keycloak.authentication.AuthenticatorFactory diff --git a/src/keycloak/providers/keycloak-phone-provider/target/classes/META-INF/services/org.keycloak.authentication.FormActionFactory b/src/keycloak-providers/keycloak-phone-provider/target/classes/META-INF/services/org.keycloak.authentication.FormActionFactory similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider/target/classes/META-INF/services/org.keycloak.authentication.FormActionFactory rename to src/keycloak-providers/keycloak-phone-provider/target/classes/META-INF/services/org.keycloak.authentication.FormActionFactory diff --git a/src/keycloak/providers/keycloak-phone-provider/target/classes/META-INF/services/org.keycloak.authentication.RequiredActionFactory b/src/keycloak-providers/keycloak-phone-provider/target/classes/META-INF/services/org.keycloak.authentication.RequiredActionFactory similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider/target/classes/META-INF/services/org.keycloak.authentication.RequiredActionFactory rename to src/keycloak-providers/keycloak-phone-provider/target/classes/META-INF/services/org.keycloak.authentication.RequiredActionFactory diff --git a/src/keycloak/providers/keycloak-phone-provider/target/classes/META-INF/services/org.keycloak.connections.jpa.entityprovider.JpaEntityProviderFactory b/src/keycloak-providers/keycloak-phone-provider/target/classes/META-INF/services/org.keycloak.connections.jpa.entityprovider.JpaEntityProviderFactory similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider/target/classes/META-INF/services/org.keycloak.connections.jpa.entityprovider.JpaEntityProviderFactory rename to src/keycloak-providers/keycloak-phone-provider/target/classes/META-INF/services/org.keycloak.connections.jpa.entityprovider.JpaEntityProviderFactory diff --git a/src/keycloak/providers/keycloak-phone-provider/target/classes/META-INF/services/org.keycloak.credential.CredentialProviderFactory b/src/keycloak-providers/keycloak-phone-provider/target/classes/META-INF/services/org.keycloak.credential.CredentialProviderFactory similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider/target/classes/META-INF/services/org.keycloak.credential.CredentialProviderFactory rename to src/keycloak-providers/keycloak-phone-provider/target/classes/META-INF/services/org.keycloak.credential.CredentialProviderFactory diff --git a/src/keycloak/providers/keycloak-phone-provider/target/classes/META-INF/services/org.keycloak.provider.Spi b/src/keycloak-providers/keycloak-phone-provider/target/classes/META-INF/services/org.keycloak.provider.Spi similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider/target/classes/META-INF/services/org.keycloak.provider.Spi rename to src/keycloak-providers/keycloak-phone-provider/target/classes/META-INF/services/org.keycloak.provider.Spi diff --git a/src/keycloak/providers/keycloak-phone-provider/target/classes/META-INF/services/org.keycloak.services.resource.RealmResourceProviderFactory b/src/keycloak-providers/keycloak-phone-provider/target/classes/META-INF/services/org.keycloak.services.resource.RealmResourceProviderFactory similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider/target/classes/META-INF/services/org.keycloak.services.resource.RealmResourceProviderFactory rename to src/keycloak-providers/keycloak-phone-provider/target/classes/META-INF/services/org.keycloak.services.resource.RealmResourceProviderFactory diff --git a/src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/common/OptionalUtils.class b/src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/common/OptionalUtils.class similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/common/OptionalUtils.class rename to src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/common/OptionalUtils.class diff --git a/src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/Utils.class b/src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/Utils.class similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/Utils.class rename to src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/Utils.class diff --git a/src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/authenticators/browser/PhoneUsernamePasswordForm.class b/src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/authenticators/browser/PhoneUsernamePasswordForm.class similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/authenticators/browser/PhoneUsernamePasswordForm.class rename to src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/authenticators/browser/PhoneUsernamePasswordForm.class diff --git a/src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/authenticators/browser/SmsOtpMfaAuthenticator.class b/src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/authenticators/browser/SmsOtpMfaAuthenticator.class similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/authenticators/browser/SmsOtpMfaAuthenticator.class rename to src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/authenticators/browser/SmsOtpMfaAuthenticator.class diff --git a/src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/authenticators/browser/SmsOtpMfaAuthenticatorFactory.class b/src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/authenticators/browser/SmsOtpMfaAuthenticatorFactory.class similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/authenticators/browser/SmsOtpMfaAuthenticatorFactory.class rename to src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/authenticators/browser/SmsOtpMfaAuthenticatorFactory.class diff --git a/src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/authenticators/conditional/ConditionalPhoneProvided.class b/src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/authenticators/conditional/ConditionalPhoneProvided.class similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/authenticators/conditional/ConditionalPhoneProvided.class rename to src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/authenticators/conditional/ConditionalPhoneProvided.class diff --git a/src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/authenticators/conditional/ConditionalPhoneProvidedFactory.class b/src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/authenticators/conditional/ConditionalPhoneProvidedFactory.class similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/authenticators/conditional/ConditionalPhoneProvidedFactory.class rename to src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/authenticators/conditional/ConditionalPhoneProvidedFactory.class diff --git a/src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/authenticators/directgrant/AuthenticationCodeAuthenticator.class b/src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/authenticators/directgrant/AuthenticationCodeAuthenticator.class similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/authenticators/directgrant/AuthenticationCodeAuthenticator.class rename to src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/authenticators/directgrant/AuthenticationCodeAuthenticator.class diff --git a/src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/authenticators/directgrant/AuthenticationCodeAuthenticatorFactory.class b/src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/authenticators/directgrant/AuthenticationCodeAuthenticatorFactory.class similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/authenticators/directgrant/AuthenticationCodeAuthenticatorFactory.class rename to src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/authenticators/directgrant/AuthenticationCodeAuthenticatorFactory.class diff --git a/src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/authenticators/directgrant/BaseDirectGrantAuthenticator.class b/src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/authenticators/directgrant/BaseDirectGrantAuthenticator.class similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/authenticators/directgrant/BaseDirectGrantAuthenticator.class rename to src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/authenticators/directgrant/BaseDirectGrantAuthenticator.class diff --git a/src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/authenticators/directgrant/EverybodyPhoneAuthenticator.class b/src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/authenticators/directgrant/EverybodyPhoneAuthenticator.class similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/authenticators/directgrant/EverybodyPhoneAuthenticator.class rename to src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/authenticators/directgrant/EverybodyPhoneAuthenticator.class diff --git a/src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/authenticators/directgrant/EverybodyPhoneAuthenticatorFactory.class b/src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/authenticators/directgrant/EverybodyPhoneAuthenticatorFactory.class similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/authenticators/directgrant/EverybodyPhoneAuthenticatorFactory.class rename to src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/authenticators/directgrant/EverybodyPhoneAuthenticatorFactory.class diff --git a/src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/authenticators/directgrant/PhoneNumberAuthenticator.class b/src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/authenticators/directgrant/PhoneNumberAuthenticator.class similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/authenticators/directgrant/PhoneNumberAuthenticator.class rename to src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/authenticators/directgrant/PhoneNumberAuthenticator.class diff --git a/src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/authenticators/directgrant/PhoneNumberAuthenticatorFactory.class b/src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/authenticators/directgrant/PhoneNumberAuthenticatorFactory.class similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/authenticators/directgrant/PhoneNumberAuthenticatorFactory.class rename to src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/authenticators/directgrant/PhoneNumberAuthenticatorFactory.class diff --git a/src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/authenticators/resetcred/ResetCredentialEmailWithPhone.class b/src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/authenticators/resetcred/ResetCredentialEmailWithPhone.class similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/authenticators/resetcred/ResetCredentialEmailWithPhone.class rename to src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/authenticators/resetcred/ResetCredentialEmailWithPhone.class diff --git a/src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/authenticators/resetcred/ResetCredentialWithPhone.class b/src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/authenticators/resetcred/ResetCredentialWithPhone.class similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/authenticators/resetcred/ResetCredentialWithPhone.class rename to src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/authenticators/resetcred/ResetCredentialWithPhone.class diff --git a/src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/forms/RegistrationPhoneUserCreation.class b/src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/forms/RegistrationPhoneUserCreation.class similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/forms/RegistrationPhoneUserCreation.class rename to src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/forms/RegistrationPhoneUserCreation.class diff --git a/src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/forms/RegistrationPhoneVerificationCode.class b/src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/forms/RegistrationPhoneVerificationCode.class similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/forms/RegistrationPhoneVerificationCode.class rename to src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/forms/RegistrationPhoneVerificationCode.class diff --git a/src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/forms/RegistrationRedirectParametersReader.class b/src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/forms/RegistrationRedirectParametersReader.class similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/forms/RegistrationRedirectParametersReader.class rename to src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/forms/RegistrationRedirectParametersReader.class diff --git a/src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/forms/SupportPhonePages$Errors.class b/src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/forms/SupportPhonePages$Errors.class similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/forms/SupportPhonePages$Errors.class rename to src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/forms/SupportPhonePages$Errors.class diff --git a/src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/forms/SupportPhonePages.class b/src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/forms/SupportPhonePages.class similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/forms/SupportPhonePages.class rename to src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/forms/SupportPhonePages.class diff --git a/src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/requiredactions/ConfigSmsOtpRequiredAction.class b/src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/requiredactions/ConfigSmsOtpRequiredAction.class similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/requiredactions/ConfigSmsOtpRequiredAction.class rename to src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/requiredactions/ConfigSmsOtpRequiredAction.class diff --git a/src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/requiredactions/ConfigSmsOtpRequiredActionFactory.class b/src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/requiredactions/ConfigSmsOtpRequiredActionFactory.class similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/requiredactions/ConfigSmsOtpRequiredActionFactory.class rename to src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/requiredactions/ConfigSmsOtpRequiredActionFactory.class diff --git a/src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/requiredactions/UpdatePhoneNumberRequiredAction.class b/src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/requiredactions/UpdatePhoneNumberRequiredAction.class similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/requiredactions/UpdatePhoneNumberRequiredAction.class rename to src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/requiredactions/UpdatePhoneNumberRequiredAction.class diff --git a/src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/requiredactions/UpdatePhoneNumberRequiredActionFactory.class b/src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/requiredactions/UpdatePhoneNumberRequiredActionFactory.class similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/requiredactions/UpdatePhoneNumberRequiredActionFactory.class rename to src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/authentication/requiredactions/UpdatePhoneNumberRequiredActionFactory.class diff --git a/src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/credential/PhoneOtpCredentialModel$EmptySecretData.class b/src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/credential/PhoneOtpCredentialModel$EmptySecretData.class similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/credential/PhoneOtpCredentialModel$EmptySecretData.class rename to src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/credential/PhoneOtpCredentialModel$EmptySecretData.class diff --git a/src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/credential/PhoneOtpCredentialModel$SmsOtpCredentialData.class b/src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/credential/PhoneOtpCredentialModel$SmsOtpCredentialData.class similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/credential/PhoneOtpCredentialModel$SmsOtpCredentialData.class rename to src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/credential/PhoneOtpCredentialModel$SmsOtpCredentialData.class diff --git a/src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/credential/PhoneOtpCredentialModel.class b/src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/credential/PhoneOtpCredentialModel.class similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/credential/PhoneOtpCredentialModel.class rename to src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/credential/PhoneOtpCredentialModel.class diff --git a/src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/credential/PhoneOtpCredentialProvider.class b/src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/credential/PhoneOtpCredentialProvider.class similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/credential/PhoneOtpCredentialProvider.class rename to src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/credential/PhoneOtpCredentialProvider.class diff --git a/src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/credential/PhoneOtpCredentialProviderFactory.class b/src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/credential/PhoneOtpCredentialProviderFactory.class similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/credential/PhoneOtpCredentialProviderFactory.class rename to src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/credential/PhoneOtpCredentialProviderFactory.class diff --git a/src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/constants/TokenCodeType.class b/src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/constants/TokenCodeType.class similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/constants/TokenCodeType.class rename to src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/constants/TokenCodeType.class diff --git a/src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/exception/MessageSendException.class b/src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/exception/MessageSendException.class similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/exception/MessageSendException.class rename to src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/exception/MessageSendException.class diff --git a/src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/exception/PhoneNumberInvalidException$ErrorType.class b/src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/exception/PhoneNumberInvalidException$ErrorType.class similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/exception/PhoneNumberInvalidException$ErrorType.class rename to src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/exception/PhoneNumberInvalidException$ErrorType.class diff --git a/src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/exception/PhoneNumberInvalidException.class b/src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/exception/PhoneNumberInvalidException.class similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/exception/PhoneNumberInvalidException.class rename to src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/exception/PhoneNumberInvalidException.class diff --git a/src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/jpa/TokenCode.class b/src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/jpa/TokenCode.class similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/jpa/TokenCode.class rename to src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/jpa/TokenCode.class diff --git a/src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/jpa/TokenCodeJpaEntityProvider.class b/src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/jpa/TokenCodeJpaEntityProvider.class similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/jpa/TokenCodeJpaEntityProvider.class rename to src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/jpa/TokenCodeJpaEntityProvider.class diff --git a/src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/jpa/TokenCodeJpaEntityProviderFactory.class b/src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/jpa/TokenCodeJpaEntityProviderFactory.class similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/jpa/TokenCodeJpaEntityProviderFactory.class rename to src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/jpa/TokenCodeJpaEntityProviderFactory.class diff --git a/src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/representations/TokenCodeRepresentation.class b/src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/representations/TokenCodeRepresentation.class similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/representations/TokenCodeRepresentation.class rename to src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/representations/TokenCodeRepresentation.class diff --git a/src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/rest/SmsResource.class b/src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/rest/SmsResource.class similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/rest/SmsResource.class rename to src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/rest/SmsResource.class diff --git a/src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/rest/SmsResourceProvider.class b/src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/rest/SmsResourceProvider.class similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/rest/SmsResourceProvider.class rename to src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/rest/SmsResourceProvider.class diff --git a/src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/rest/SmsResourceProviderFactory.class b/src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/rest/SmsResourceProviderFactory.class similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/rest/SmsResourceProviderFactory.class rename to src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/rest/SmsResourceProviderFactory.class diff --git a/src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/rest/TokenCodeResource.class b/src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/rest/TokenCodeResource.class similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/rest/TokenCodeResource.class rename to src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/rest/TokenCodeResource.class diff --git a/src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/rest/VerificationCodeResource.class b/src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/rest/VerificationCodeResource.class similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/rest/VerificationCodeResource.class rename to src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/rest/VerificationCodeResource.class diff --git a/src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/spi/FullSmsSenderAbstractService.class b/src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/spi/FullSmsSenderAbstractService.class similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/spi/FullSmsSenderAbstractService.class rename to src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/spi/FullSmsSenderAbstractService.class diff --git a/src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/spi/MessageSenderService.class b/src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/spi/MessageSenderService.class similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/spi/MessageSenderService.class rename to src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/spi/MessageSenderService.class diff --git a/src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/spi/MessageSenderServiceProviderFactory.class b/src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/spi/MessageSenderServiceProviderFactory.class similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/spi/MessageSenderServiceProviderFactory.class rename to src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/spi/MessageSenderServiceProviderFactory.class diff --git a/src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/spi/MessageSenderServiceSpi.class b/src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/spi/MessageSenderServiceSpi.class similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/spi/MessageSenderServiceSpi.class rename to src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/spi/MessageSenderServiceSpi.class diff --git a/src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/spi/PhoneProvider.class b/src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/spi/PhoneProvider.class similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/spi/PhoneProvider.class rename to src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/spi/PhoneProvider.class diff --git a/src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/spi/PhoneProviderFactory.class b/src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/spi/PhoneProviderFactory.class similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/spi/PhoneProviderFactory.class rename to src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/spi/PhoneProviderFactory.class diff --git a/src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/spi/PhoneSpi.class b/src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/spi/PhoneSpi.class similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/spi/PhoneSpi.class rename to src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/spi/PhoneSpi.class diff --git a/src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/spi/PhoneVerificationCodeProvider.class b/src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/spi/PhoneVerificationCodeProvider.class similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/spi/PhoneVerificationCodeProvider.class rename to src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/spi/PhoneVerificationCodeProvider.class diff --git a/src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/spi/PhoneVerificationCodeProviderFactory.class b/src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/spi/PhoneVerificationCodeProviderFactory.class similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/spi/PhoneVerificationCodeProviderFactory.class rename to src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/spi/PhoneVerificationCodeProviderFactory.class diff --git a/src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/spi/PhoneVerificationCodeSpi.class b/src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/spi/PhoneVerificationCodeSpi.class similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/spi/PhoneVerificationCodeSpi.class rename to src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/spi/PhoneVerificationCodeSpi.class diff --git a/src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/spi/impl/DefaultPhoneProvider.class b/src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/spi/impl/DefaultPhoneProvider.class similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/spi/impl/DefaultPhoneProvider.class rename to src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/spi/impl/DefaultPhoneProvider.class diff --git a/src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/spi/impl/DefaultPhoneProviderFactory.class b/src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/spi/impl/DefaultPhoneProviderFactory.class similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/spi/impl/DefaultPhoneProviderFactory.class rename to src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/spi/impl/DefaultPhoneProviderFactory.class diff --git a/src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/spi/impl/DefaultPhoneVerificationCodeProvider.class b/src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/spi/impl/DefaultPhoneVerificationCodeProvider.class similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/spi/impl/DefaultPhoneVerificationCodeProvider.class rename to src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/spi/impl/DefaultPhoneVerificationCodeProvider.class diff --git a/src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/spi/impl/DefaultVerificationCodeProviderFactory.class b/src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/spi/impl/DefaultVerificationCodeProviderFactory.class similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/spi/impl/DefaultVerificationCodeProviderFactory.class rename to src/keycloak-providers/keycloak-phone-provider/target/classes/cc/coopersoft/keycloak/phone/providers/spi/impl/DefaultVerificationCodeProviderFactory.class diff --git a/src/keycloak/providers/keycloak-phone-provider/target/keycloak-phone-provider-2.3.4-snapshot.jar b/src/keycloak-providers/keycloak-phone-provider/target/keycloak-phone-provider-2.3.4-snapshot.jar similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider/target/keycloak-phone-provider-2.3.4-snapshot.jar rename to src/keycloak-providers/keycloak-phone-provider/target/keycloak-phone-provider-2.3.4-snapshot.jar diff --git a/src/keycloak/providers/keycloak-phone-provider/target/maven-archiver/pom.properties b/src/keycloak-providers/keycloak-phone-provider/target/maven-archiver/pom.properties similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider/target/maven-archiver/pom.properties rename to src/keycloak-providers/keycloak-phone-provider/target/maven-archiver/pom.properties diff --git a/src/keycloak/providers/keycloak-phone-provider/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst b/src/keycloak-providers/keycloak-phone-provider/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst rename to src/keycloak-providers/keycloak-phone-provider/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst diff --git a/src/keycloak/providers/keycloak-phone-provider/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst b/src/keycloak-providers/keycloak-phone-provider/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst rename to src/keycloak-providers/keycloak-phone-provider/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst diff --git a/src/keycloak/providers/keycloak-phone-provider/target/maven-status/maven-compiler-plugin/testCompile/default-testCompile/createdFiles.lst b/src/keycloak-providers/keycloak-phone-provider/target/maven-status/maven-compiler-plugin/testCompile/default-testCompile/createdFiles.lst similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider/target/maven-status/maven-compiler-plugin/testCompile/default-testCompile/createdFiles.lst rename to src/keycloak-providers/keycloak-phone-provider/target/maven-status/maven-compiler-plugin/testCompile/default-testCompile/createdFiles.lst diff --git a/src/keycloak/providers/keycloak-phone-provider/target/maven-status/maven-compiler-plugin/testCompile/default-testCompile/inputFiles.lst b/src/keycloak-providers/keycloak-phone-provider/target/maven-status/maven-compiler-plugin/testCompile/default-testCompile/inputFiles.lst similarity index 100% rename from src/keycloak/providers/keycloak-phone-provider/target/maven-status/maven-compiler-plugin/testCompile/default-testCompile/inputFiles.lst rename to src/keycloak-providers/keycloak-phone-provider/target/maven-status/maven-compiler-plugin/testCompile/default-testCompile/inputFiles.lst diff --git a/src/keycloak/providers/keycloak-sms-provider-dummy/target/classes/META-INF/services/cc.coopersoft.keycloak.phone.providers.spi.MessageSenderServiceProviderFactory b/src/keycloak-providers/keycloak-sms-provider-dummy/target/classes/META-INF/services/cc.coopersoft.keycloak.phone.providers.spi.MessageSenderServiceProviderFactory similarity index 100% rename from src/keycloak/providers/keycloak-sms-provider-dummy/target/classes/META-INF/services/cc.coopersoft.keycloak.phone.providers.spi.MessageSenderServiceProviderFactory rename to src/keycloak-providers/keycloak-sms-provider-dummy/target/classes/META-INF/services/cc.coopersoft.keycloak.phone.providers.spi.MessageSenderServiceProviderFactory diff --git a/src/keycloak/providers/keycloak-sms-provider-dummy/target/classes/cc/coopersoft/keycloak/phone/providers/sender/DummyMessageSenderServiceProviderFactory.class b/src/keycloak-providers/keycloak-sms-provider-dummy/target/classes/cc/coopersoft/keycloak/phone/providers/sender/DummyMessageSenderServiceProviderFactory.class similarity index 100% rename from src/keycloak/providers/keycloak-sms-provider-dummy/target/classes/cc/coopersoft/keycloak/phone/providers/sender/DummyMessageSenderServiceProviderFactory.class rename to src/keycloak-providers/keycloak-sms-provider-dummy/target/classes/cc/coopersoft/keycloak/phone/providers/sender/DummyMessageSenderServiceProviderFactory.class diff --git a/src/keycloak/providers/keycloak-sms-provider-dummy/target/classes/cc/coopersoft/keycloak/phone/providers/sender/DummySmsSenderService.class b/src/keycloak-providers/keycloak-sms-provider-dummy/target/classes/cc/coopersoft/keycloak/phone/providers/sender/DummySmsSenderService.class similarity index 100% rename from src/keycloak/providers/keycloak-sms-provider-dummy/target/classes/cc/coopersoft/keycloak/phone/providers/sender/DummySmsSenderService.class rename to src/keycloak-providers/keycloak-sms-provider-dummy/target/classes/cc/coopersoft/keycloak/phone/providers/sender/DummySmsSenderService.class diff --git a/src/keycloak/providers/keycloak-sms-provider-dummy/target/keycloak-sms-provider-dummy-2.3.4-snapshot.jar b/src/keycloak-providers/keycloak-sms-provider-dummy/target/keycloak-sms-provider-dummy-2.3.4-snapshot.jar similarity index 100% rename from src/keycloak/providers/keycloak-sms-provider-dummy/target/keycloak-sms-provider-dummy-2.3.4-snapshot.jar rename to src/keycloak-providers/keycloak-sms-provider-dummy/target/keycloak-sms-provider-dummy-2.3.4-snapshot.jar diff --git a/src/keycloak/providers/keycloak-sms-provider-dummy/target/maven-archiver/pom.properties b/src/keycloak-providers/keycloak-sms-provider-dummy/target/maven-archiver/pom.properties similarity index 100% rename from src/keycloak/providers/keycloak-sms-provider-dummy/target/maven-archiver/pom.properties rename to src/keycloak-providers/keycloak-sms-provider-dummy/target/maven-archiver/pom.properties diff --git a/src/keycloak/providers/keycloak-sms-provider-dummy/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst b/src/keycloak-providers/keycloak-sms-provider-dummy/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst similarity index 100% rename from src/keycloak/providers/keycloak-sms-provider-dummy/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst rename to src/keycloak-providers/keycloak-sms-provider-dummy/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst diff --git a/src/keycloak/providers/keycloak-sms-provider-dummy/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst b/src/keycloak-providers/keycloak-sms-provider-dummy/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst similarity index 100% rename from src/keycloak/providers/keycloak-sms-provider-dummy/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst rename to src/keycloak-providers/keycloak-sms-provider-dummy/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst diff --git a/src/keycloak/providers/keycloak-sms-provider-twilio/target/archive-tmp/keycloak-sms-provider-twilio-2.3.4-snapshot.jar b/src/keycloak-providers/keycloak-sms-provider-twilio/target/archive-tmp/keycloak-sms-provider-twilio-2.3.4-snapshot.jar similarity index 100% rename from src/keycloak/providers/keycloak-sms-provider-twilio/target/archive-tmp/keycloak-sms-provider-twilio-2.3.4-snapshot.jar rename to src/keycloak-providers/keycloak-sms-provider-twilio/target/archive-tmp/keycloak-sms-provider-twilio-2.3.4-snapshot.jar diff --git a/src/keycloak/providers/keycloak-sms-provider-twilio/target/classes/META-INF/services/cc.coopersoft.keycloak.phone.providers.spi.MessageSenderServiceProviderFactory b/src/keycloak-providers/keycloak-sms-provider-twilio/target/classes/META-INF/services/cc.coopersoft.keycloak.phone.providers.spi.MessageSenderServiceProviderFactory similarity index 100% rename from src/keycloak/providers/keycloak-sms-provider-twilio/target/classes/META-INF/services/cc.coopersoft.keycloak.phone.providers.spi.MessageSenderServiceProviderFactory rename to src/keycloak-providers/keycloak-sms-provider-twilio/target/classes/META-INF/services/cc.coopersoft.keycloak.phone.providers.spi.MessageSenderServiceProviderFactory diff --git a/src/keycloak/providers/keycloak-sms-provider-twilio/target/classes/cc/coopersoft/keycloak/phone/providers/sender/TwilioMessageSenderServiceProviderFactory.class b/src/keycloak-providers/keycloak-sms-provider-twilio/target/classes/cc/coopersoft/keycloak/phone/providers/sender/TwilioMessageSenderServiceProviderFactory.class similarity index 100% rename from src/keycloak/providers/keycloak-sms-provider-twilio/target/classes/cc/coopersoft/keycloak/phone/providers/sender/TwilioMessageSenderServiceProviderFactory.class rename to src/keycloak-providers/keycloak-sms-provider-twilio/target/classes/cc/coopersoft/keycloak/phone/providers/sender/TwilioMessageSenderServiceProviderFactory.class diff --git a/src/keycloak/providers/keycloak-sms-provider-twilio/target/classes/cc/coopersoft/keycloak/phone/providers/sender/TwilioSmsSenderServiceProvider.class b/src/keycloak-providers/keycloak-sms-provider-twilio/target/classes/cc/coopersoft/keycloak/phone/providers/sender/TwilioSmsSenderServiceProvider.class similarity index 100% rename from src/keycloak/providers/keycloak-sms-provider-twilio/target/classes/cc/coopersoft/keycloak/phone/providers/sender/TwilioSmsSenderServiceProvider.class rename to src/keycloak-providers/keycloak-sms-provider-twilio/target/classes/cc/coopersoft/keycloak/phone/providers/sender/TwilioSmsSenderServiceProvider.class diff --git a/src/keycloak/providers/keycloak-sms-provider-twilio/target/keycloak-sms-provider-twilio-2.3.4-snapshot.jar b/src/keycloak-providers/keycloak-sms-provider-twilio/target/keycloak-sms-provider-twilio-2.3.4-snapshot.jar similarity index 100% rename from src/keycloak/providers/keycloak-sms-provider-twilio/target/keycloak-sms-provider-twilio-2.3.4-snapshot.jar rename to src/keycloak-providers/keycloak-sms-provider-twilio/target/keycloak-sms-provider-twilio-2.3.4-snapshot.jar diff --git a/src/keycloak/providers/keycloak-sms-provider-twilio/target/maven-archiver/pom.properties b/src/keycloak-providers/keycloak-sms-provider-twilio/target/maven-archiver/pom.properties similarity index 100% rename from src/keycloak/providers/keycloak-sms-provider-twilio/target/maven-archiver/pom.properties rename to src/keycloak-providers/keycloak-sms-provider-twilio/target/maven-archiver/pom.properties diff --git a/src/keycloak/providers/keycloak-sms-provider-twilio/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst b/src/keycloak-providers/keycloak-sms-provider-twilio/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst similarity index 100% rename from src/keycloak/providers/keycloak-sms-provider-twilio/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst rename to src/keycloak-providers/keycloak-sms-provider-twilio/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst diff --git a/src/keycloak/providers/keycloak-sms-provider-twilio/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst b/src/keycloak-providers/keycloak-sms-provider-twilio/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst similarity index 100% rename from src/keycloak/providers/keycloak-sms-provider-twilio/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst rename to src/keycloak-providers/keycloak-sms-provider-twilio/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst diff --git a/src/keycloak/exports/01-yoma-realm.yaml b/src/keycloak/exports/01-yoma-realm.yaml index daf4c5741..c0282e1c4 100644 --- a/src/keycloak/exports/01-yoma-realm.yaml +++ b/src/keycloak/exports/01-yoma-realm.yaml @@ -28,7 +28,7 @@ oauth2DevicePollingInterval: 5 enabled: true sslRequired: external registrationAllowed: true -registrationEmailAsUsername: true +registrationEmailAsUsername: false #JASON: rememberMe: false verifyEmail: true loginWithEmailAllowed: true @@ -1350,10 +1350,10 @@ smtpServer: fromDisplayName: Yoma user: apikey verifyLinkExpiration: 86400 -loginTheme: yoma +loginTheme: phone accountTheme: "" adminTheme: "" -emailTheme: yoma +emailTheme: phone eventsEnabled: true eventsListeners: - jboss-logging @@ -1438,6 +1438,7 @@ enabledEventTypes: - VERIFY_PROFILE - GRANT_CONSENT_ERROR - IDENTITY_PROVIDER_FIRST_LOGIN_ERROR + - UPDATE_PHONE_NUMBER adminEventsEnabled: true adminEventsDetailsEnabled: false identityProviders: [] @@ -1949,6 +1950,154 @@ authenticationFlows: priority: 10 autheticatorFlow: false userSetupAllowed: false + - id: 367ab94c-3d18-453d-9480-ca28d87e9a51 + alias: Registration with phone + description: registration flow with phone (custom) + providerId: basic-flow + topLevel: true + builtIn: false + authenticationExecutions: + - authenticator: registration-page-form + authenticatorFlow: true + requirement: REQUIRED + priority: 10 + autheticatorFlow: true + flowAlias: Registration with phone registration form + userSetupAllowed: false + - id: 65aa62f7-db58-4467-b01f-152a7387e120 + alias: Registration with phone registration form + description: registration form + providerId: form-flow + topLevel: false + builtIn: false + authenticationExecutions: + - authenticatorConfig: registration phone user creation config + authenticator: registration-phone-username-creation + authenticatorFlow: false + requirement: REQUIRED + priority: 20 + autheticatorFlow: false + userSetupAllowed: false + - authenticator: registration-profile-action + authenticatorFlow: false + requirement: REQUIRED + priority: 50 + autheticatorFlow: false + userSetupAllowed: false + - authenticator: registration-password-action + authenticatorFlow: false + requirement: REQUIRED + priority: 60 + autheticatorFlow: false + userSetupAllowed: false + - authenticatorConfig: phone validation config + authenticator: registration-phone + authenticatorFlow: false + requirement: REQUIRED + priority: 61 + autheticatorFlow: false + userSetupAllowed: false + - authenticator: registration-recaptcha-action + authenticatorFlow: false + requirement: DISABLED + priority: 62 + autheticatorFlow: false + userSetupAllowed: false + - id: c80827d1-4db5-4541-b1ac-4a3131407bb4 + alias: Browser with phone + description: browser with phone based authentication (custom) + providerId: basic-flow + topLevel: true + builtIn: false + authenticationExecutions: + - authenticator: auth-cookie + authenticatorFlow: false + requirement: ALTERNATIVE + priority: 10 + autheticatorFlow: false + userSetupAllowed: false + - authenticator: auth-spnego + authenticatorFlow: false + requirement: DISABLED + priority: 20 + autheticatorFlow: false + userSetupAllowed: false + - authenticator: identity-provider-redirector + authenticatorFlow: false + requirement: ALTERNATIVE + priority: 25 + autheticatorFlow: false + userSetupAllowed: false + - authenticatorFlow: true + requirement: ALTERNATIVE + priority: 30 + autheticatorFlow: true + flowAlias: Browser with phone forms + userSetupAllowed: false + - id: 7a1f2f13-cdfa-4a36-889c-ddb2f4a3c533 + alias: Browser with phone Browser - Conditional OTP + description: Flow to determine if the OTP is required for the authentication + providerId: basic-flow + topLevel: false + builtIn: false + authenticationExecutions: + - authenticator: conditional-user-configured + authenticatorFlow: false + requirement: REQUIRED + priority: 10 + autheticatorFlow: false + userSetupAllowed: false + - authenticator: auth-otp-form + authenticatorFlow: false + requirement: REQUIRED + priority: 20 + autheticatorFlow: false + userSetupAllowed: false + - id: 34029838-49fa-4a78-8c37-77ce85c5dfc0 + alias: Browser with phone forms + description: Username, password, otp and other auth forms. + providerId: basic-flow + topLevel: false + builtIn: false + authenticationExecutions: + - authenticatorConfig: phone username password form config + authenticator: auth-phone-username-password-form + authenticatorFlow: false + requirement: REQUIRED + priority: 10 + autheticatorFlow: false + userSetupAllowed: false + - authenticatorFlow: true + requirement: CONDITIONAL + priority: 21 + autheticatorFlow: true + flowAlias: Browser with phone Browser - Conditional OTP + userSetupAllowed: false + - id: 9e3caf1a-915f-47ff-a880-0bbba9029811 + alias: Reset credentials with phone + description: Reset credentials with phone (custom) + providerId: basic-flow + topLevel: true + builtIn: false + authenticationExecutions: + - authenticator: reset-credentials-with-phone + authenticatorFlow: false + requirement: REQUIRED + priority: 0 + autheticatorFlow: false + userSetupAllowed: false + - authenticator: reset-credential-email-with-phone + authenticatorFlow: false + requirement: CONDITIONAL + priority: 1 + autheticatorFlow: false + userSetupAllowed: false + - authenticator: reset-password + authenticatorFlow: false + requirement: REQUIRED + priority: 2 + autheticatorFlow: false + userSetupAllowed: false authenticatorConfig: - id: 577777dc-5d9d-492c-adcf-d84f0b9295b6 alias: create unique user config @@ -1958,6 +2107,19 @@ authenticatorConfig: alias: review profile config config: update.profile.on.first.login: missing + - id: 9d873a46-e2fc-4ba0-8b04-8c6f35a0436c + alias: phone validation config + config: + createOPTCredential: "true" + - id: 32768293-fec2-441d-ac42-71d304161394 + alias: registration phone user creation config + config: + phoneNumberAsUsername: "false" + - id: 1b3b08ec-1a23-4a26-8efd-ffb0ec2543e0 + alias: phone username password form config + config: + loginWithPhoneNumber: "true" + loginWithPhoneVerify: "true" requiredActions: - alias: CONFIGURE_TOTP name: Configure OTP @@ -1994,26 +2156,33 @@ requiredActions: defaultAction: false priority: 50 config: {} + - alias: UPDATE_PHONE_NUMBER + name: Update Phone Number + providerId: UPDATE_PHONE_NUMBER + enabled: true + defaultAction: false + priority: 60 + config: {} - alias: delete_account name: Delete Account providerId: delete_account enabled: false defaultAction: false - priority: 60 + priority: 70 config: {} - alias: webauthn-register name: Webauthn Register providerId: webauthn-register enabled: true defaultAction: false - priority: 70 + priority: 80 config: {} - alias: webauthn-register-passwordless name: Webauthn Register Passwordless providerId: webauthn-register-passwordless enabled: true defaultAction: false - priority: 80 + priority: 90 config: {} - alias: update_user_locale name: Update User Locale @@ -2022,10 +2191,10 @@ requiredActions: defaultAction: false priority: 1000 config: {} -browserFlow: browser -registrationFlow: registration +browserFlow: Browser with phone +registrationFlow: Registration with phone directGrantFlow: direct grant -resetCredentialsFlow: reset credentials +resetCredentialsFlow: Reset credentials with phone clientAuthenticationFlow: clients dockerAuthenticationFlow: docker auth keycloakVersion: 22.0.1 diff --git a/src/keycloak/exports/02-yoma-profile.yaml b/src/keycloak/exports/02-yoma-profile.yaml index 55d7df452..31e56333f 100644 --- a/src/keycloak/exports/02-yoma-profile.yaml +++ b/src/keycloak/exports/02-yoma-profile.yaml @@ -5,422 +5,398 @@ attributes: userProfileEnabled: "true" userProfile: attributes: - - name: username - displayName: '${username}' - validations: - length: - min: 3 - max: 255 - username-prohibited-characters: {} - - name: email - displayName: '${email}' - validations: - email: {} - length: - max: 255 - - name: firstName - displayName: '${firstName}' - required: - roles: - - user - scopes: [] - permissions: - view: - - user - - admin - edit: - - user - - admin - validations: - length: - max: 255 - person-name-prohibited-characters: {} - selector: - scopes: [] - - name: lastName - displayName: '${lastName}' - required: - roles: - - user - scopes: [] - permissions: - view: - - user - - admin - edit: - - user - - admin - validations: - length: - max: 255 - person-name-prohibited-characters: {} - selector: - scopes: [] - - name: country - displayName: Country - selector: - scopes: [] - permissions: - edit: - - user - - admin - view: - - user - - admin - annotations: - inputType: select - validations: - options: + - name: username + displayName: "${username}" + - name: phoneNumber + displayName: "Phone number" + permissions: + view: + - admin + - user + edit: + - user + selector: + scopes: [] + validations: + pattern: + pattern: "^\\+?\\d+$" + error-message: Phone number is not valid + - name: phoneNumberVerified + displayName: "Phone number verified" + permissions: + view: + - admin + edit: + - admin + selector: + scopes: [] + - name: email + displayName: "${email}" + validations: + email: {} + length: + max: 255 + - name: firstName + displayName: "${firstName}" + permissions: + view: + - user + - admin + edit: + - user + - admin + validations: + length: + max: 255 + person-name-prohibited-characters: {} + selector: + scopes: [] + - name: lastName + displayName: "${lastName}" + permissions: + view: + - user + - admin + edit: + - user + - admin + validations: + length: + max: 255 + person-name-prohibited-characters: {} + selector: + scopes: [] + - name: country + displayName: Country + selector: + scopes: [] + permissions: + edit: + - user + - admin + view: + - user + - admin + annotations: + inputType: select + validations: options: - - Afghanistan - - Åland Islands - - Albania - - Algeria - - American Samoa - - Andorra - - Angola - - Anguilla - - Antarctica - - Antigua and Barbuda - - Argentina - - Armenia - - Aruba - - Australia - - Austria - - Azerbaijan - - Bahamas - - Bahrain - - Bangladesh - - Barbados - - Belarus - - Belgium - - Belize - - Benin - - Bermuda - - Bhutan - - Bolivia - - Bosnia and Herzegovina - - Botswana - - Bouvet Island - - Brazil - - British Indian Ocean Territory - - British Virgin Islands - - Brunei - - Bulgaria - - Burkina Faso - - Burundi - - Cambodia - - Cameroon - - Canada - - Cape Verde - - Caribbean Netherlands - - Cayman Islands - - Central African Republic - - Chad - - Chile - - China - - Christmas Island - - Cocos (Keeling) Islands - - Colombia - - Comoros - - DR Congo - - Cook Islands - - Costa Rica - - Croatia - - Cuba - - Curaçao - - Cyprus - - Czechia - - Denmark - - Djibouti - - Dominica - - Dominican Republic - - Ecuador - - Egypt - - El Salvador - - Equatorial Guinea - - Eritrea - - Estonia - - Ethiopia - - Falkland Islands - - Faroe Islands - - Fiji - - Finland - - France - - French Guiana - - French Polynesia - - French Southern and Antarctic Lands - - Gabon - - Gambia - - Georgia - - Germany - - Ghana - - Gibraltar - - Greece - - Greenland - - Grenada - - Guadeloupe - - Guam - - Guatemala - - Guernsey - - Guinea-Bissau - - Guinea - - Guyana - - Haiti - - Heard Island and McDonald Islands - - Honduras - - Hong Kong - - Hungary - - Iceland - - India - - Indonesia - - Iran - - Iraq - - Ireland - - Isle of Man - - Israel - - Italy - - Ivory Coast - - Jamaica - - Japan - - Jersey - - Jordan - - Kazakhstan - - Kenya - - Kiribati - - Kuwait - - Kyrgyzstan - - Laos - - Latvia - - Lebanon - - Lesotho - - Liberia - - Libya - - Liechtenstein - - Lithuania - - Luxembourg - - Macau - - Madagascar - - Malawi - - Malaysia - - Maldives - - Mali - - Malta - - Marshall Islands - - Martinique - - Mauritania - - Mauritius - - Mayotte - - Mexico - - Micronesia - - Moldova - - Monaco - - Mongolia - - Montenegro - - Montserrat - - Morocco - - Mozambique - - Myanmar - - Namibia - - Nauru - - Nepal - - Netherlands - - New Caledonia - - New Zealand - - Nicaragua - - Nigeria - - Niger - - Niue - - Norfolk Island - - Northern Mariana Islands - - North Korea - - North Macedonia - - Norway - - Oman - - Pakistan - - Palau - - Palestine - - Panama - - Papua New Guinea - - Paraguay - - Peru - - Philippines - - Pitcairn Islands - - Poland - - Portugal - - Puerto Rico - - Qatar - - Republic of the Congo - - Réunion - - Romania - - Russia - - Rwanda - - Saint Barthélemy - - 'Saint Helena, Ascension and Tristan da Cunha' - - Saint Kitts and Nevis - - Saint Lucia - - Saint Martin - - Saint Pierre and Miquelon - - Saint Vincent and the Grenadines - - Samoa - - San Marino - - São Tomé and Príncipe - - Saudi Arabia - - Senegal - - Serbia - - Seychelles - - Sierra Leone - - Singapore - - Sint Maarten - - Slovakia - - Slovenia - - Solomon Islands - - Somalia - - South Africa - - South Georgia - - South Korea - - South Sudan - - Spain - - Sri Lanka - - Sudan - - Suriname - - Svalbard and Jan Mayen - - Eswatini - - Sweden - - Switzerland - - Syria - - Taiwan - - Tajikistan - - Tanzania - - Thailand - - Timor-Leste - - Togo - - Tokelau - - Tonga - - Trinidad and Tobago - - Tunisia - - Turkey - - Turkmenistan - - Turks and Caicos Islands - - Tuvalu - - Uganda - - Ukraine - - United Arab Emirates - - United Kingdom - - United States - - United States Minor Outlying Islands - - United States Virgin Islands - - Uruguay - - Uzbekistan - - Vanuatu - - Vatican City - - Venezuela - - Vietnam - - Wallis and Futuna - - Western Sahara - - Yemen - - Zambia - - Zimbabwe - - Worldwide - required: - roles: - - user - scopes: [] - - name: dateOfBirth - displayName: Date of birth - required: - roles: - - user - scopes: [] - permissions: - edit: - - user - - admin - view: - - user - - admin - annotations: - inputType: date - - name: gender - displayName: Gender - selector: - scopes: [] - permissions: - edit: - - user - - admin - view: - - user - - admin - annotations: - inputType: select - validations: - options: + options: + - Afghanistan + - Åland Islands + - Albania + - Algeria + - American Samoa + - Andorra + - Angola + - Anguilla + - Antarctica + - Antigua and Barbuda + - Argentina + - Armenia + - Aruba + - Australia + - Austria + - Azerbaijan + - Bahamas + - Bahrain + - Bangladesh + - Barbados + - Belarus + - Belgium + - Belize + - Benin + - Bermuda + - Bhutan + - Bolivia + - Bosnia and Herzegovina + - Botswana + - Bouvet Island + - Brazil + - British Indian Ocean Territory + - British Virgin Islands + - Brunei + - Bulgaria + - Burkina Faso + - Burundi + - Cambodia + - Cameroon + - Canada + - Cape Verde + - Caribbean Netherlands + - Cayman Islands + - Central African Republic + - Chad + - Chile + - China + - Christmas Island + - Cocos (Keeling) Islands + - Colombia + - Comoros + - DR Congo + - Cook Islands + - Costa Rica + - Croatia + - Cuba + - Curaçao + - Cyprus + - Czechia + - Denmark + - Djibouti + - Dominica + - Dominican Republic + - Ecuador + - Egypt + - El Salvador + - Equatorial Guinea + - Eritrea + - Estonia + - Ethiopia + - Falkland Islands + - Faroe Islands + - Fiji + - Finland + - France + - French Guiana + - French Polynesia + - French Southern and Antarctic Lands + - Gabon + - Gambia + - Georgia + - Germany + - Ghana + - Gibraltar + - Greece + - Greenland + - Grenada + - Guadeloupe + - Guam + - Guatemala + - Guernsey + - Guinea-Bissau + - Guinea + - Guyana + - Haiti + - Heard Island and McDonald Islands + - Honduras + - Hong Kong + - Hungary + - Iceland + - India + - Indonesia + - Iran + - Iraq + - Ireland + - Isle of Man + - Israel + - Italy + - Ivory Coast + - Jamaica + - Japan + - Jersey + - Jordan + - Kazakhstan + - Kenya + - Kiribati + - Kuwait + - Kyrgyzstan + - Laos + - Latvia + - Lebanon + - Lesotho + - Liberia + - Libya + - Liechtenstein + - Lithuania + - Luxembourg + - Macau + - Madagascar + - Malawi + - Malaysia + - Maldives + - Mali + - Malta + - Marshall Islands + - Martinique + - Mauritania + - Mauritius + - Mayotte + - Mexico + - Micronesia + - Moldova + - Monaco + - Mongolia + - Montenegro + - Montserrat + - Morocco + - Mozambique + - Myanmar + - Namibia + - Nauru + - Nepal + - Netherlands + - New Caledonia + - New Zealand + - Nicaragua + - Nigeria + - Niger + - Niue + - Norfolk Island + - Northern Mariana Islands + - North Korea + - North Macedonia + - Norway + - Oman + - Pakistan + - Palau + - Palestine + - Panama + - Papua New Guinea + - Paraguay + - Peru + - Philippines + - Pitcairn Islands + - Poland + - Portugal + - Puerto Rico + - Qatar + - Republic of the Congo + - Réunion + - Romania + - Russia + - Rwanda + - Saint Barthélemy + - "Saint Helena, Ascension and Tristan da Cunha" + - Saint Kitts and Nevis + - Saint Lucia + - Saint Martin + - Saint Pierre and Miquelon + - Saint Vincent and the Grenadines + - Samoa + - San Marino + - São Tomé and Príncipe + - Saudi Arabia + - Senegal + - Serbia + - Seychelles + - Sierra Leone + - Singapore + - Sint Maarten + - Slovakia + - Slovenia + - Solomon Islands + - Somalia + - South Africa + - South Georgia + - South Korea + - South Sudan + - Spain + - Sri Lanka + - Sudan + - Suriname + - Svalbard and Jan Mayen + - Eswatini + - Sweden + - Switzerland + - Syria + - Taiwan + - Tajikistan + - Tanzania + - Thailand + - Timor-Leste + - Togo + - Tokelau + - Tonga + - Trinidad and Tobago + - Tunisia + - Turkey + - Turkmenistan + - Turks and Caicos Islands + - Tuvalu + - Uganda + - Ukraine + - United Arab Emirates + - United Kingdom + - United States + - United States Minor Outlying Islands + - United States Virgin Islands + - Uruguay + - Uzbekistan + - Vanuatu + - Vatican City + - Venezuela + - Vietnam + - Wallis and Futuna + - Western Sahara + - Yemen + - Zambia + - Zimbabwe + - Worldwide + - name: dateOfBirth + displayName: Date of birth + permissions: + edit: + - user + - admin + view: + - user + - admin + annotations: + inputType: date + - name: gender + displayName: Gender + selector: + scopes: [] + permissions: + edit: + - user + - admin + view: + - user + - admin + annotations: + inputType: select + validations: options: - - Male - - Female - - Prefer not to say - required: - roles: - - user - scopes: [] - - name: education - displayName: Education - selector: - scopes: [] - permissions: - edit: - - user - - admin - view: - - user - - admin - annotations: - inputType: select - validations: - options: + options: + - Male + - Female + - Prefer not to say + - name: education + displayName: Education + selector: + scopes: [] + permissions: + edit: + - user + - admin + view: + - user + - admin + annotations: + inputType: select + validations: options: - - Primary - - Secondary - - Tertiary - - No formal education - required: - roles: - - user - scopes: [] - - name: phoneNumber - displayName: 'Phone number' - permissions: - view: - - admin - edit: - - admin - required: - roles: - - user - scopes: - - phone - selector: - scopes: [] - validations: - pattern: - pattern: "^[\\+]?[(]?[0-9]{3}[)]?[-\\s\\.]?[0-9]{3}[-\\s\\.]?[0-9]{4,6}$" - error-message: Phone number is not valid - - name: terms_and_conditions - displayName: 'Terms & conditions accepted' - required: - roles: - - admin - - user - scopes: [] - permissions: - edit: - - user - view: - - admin - - user - annotations: - inputType: number + options: + - Primary + - Secondary + - Tertiary + - No formal education + - name: terms_and_conditions + displayName: "Terms & conditions accepted" + required: + roles: + - admin + - user + scopes: [] + permissions: + edit: + - user + view: + - admin + - user + annotations: + inputType: number scopeMappings: - clientScope: offline_access roles: diff --git a/src/keycloak/providers/README.md b/src/keycloak/providers/README.md new file mode 100644 index 000000000..bab1794a1 --- /dev/null +++ b/src/keycloak/providers/README.md @@ -0,0 +1,25 @@ +# Keycloak Phone Provider + +This is a custom Keycloak plugin for phone number registration and login. It has been forked from the [original project](https://github.com/cooperlyt/keycloak-phone-provider/tree/keycloak22.x.x) and updated to work with Keycloak version 22.0.1. + +## Features + +- Phone number registration +- Phone number login +- Customizable FreeMarker templates for phone number verification + +## Authentication Flows + +The following flows have been added to the realm import file (01-yoma-realm.yaml) and can be viewed in the admin console: + +- **browserFlow**: Browser with phone +- **registrationFlow**: Registration with phone +- **resetCredentialsFlow**: Reset credentials with phone + +## Customization + +Any changes to the plugin code (Java) and FreeMaker templates require the JAR file to be rebuilt. Use the following command to build the JAR file: + +```sh +mvn clean install +``` diff --git a/src/keycloak/providers/jars/keycloak-phone-provider.jar b/src/keycloak/providers/jars/keycloak-phone-provider.jar new file mode 100644 index 000000000..421a9e04d Binary files /dev/null and b/src/keycloak/providers/jars/keycloak-phone-provider.jar differ diff --git a/src/keycloak/providers/jars/keycloak-phone-provider.resources.jar b/src/keycloak/providers/jars/keycloak-phone-provider.resources.jar new file mode 100644 index 000000000..79f8c4b5a Binary files /dev/null and b/src/keycloak/providers/jars/keycloak-phone-provider.resources.jar differ diff --git a/src/keycloak/providers/jars/keycloak-sms-provider-dummy.jar b/src/keycloak/providers/jars/keycloak-sms-provider-dummy.jar new file mode 100644 index 000000000..ce4b58795 Binary files /dev/null and b/src/keycloak/providers/jars/keycloak-sms-provider-dummy.jar differ diff --git a/src/keycloak/providers/jars/keycloak-sms-provider-twilio.jar b/src/keycloak/providers/jars/keycloak-sms-provider-twilio.jar new file mode 100644 index 000000000..0690af1aa Binary files /dev/null and b/src/keycloak/providers/jars/keycloak-sms-provider-twilio.jar differ diff --git a/src/keycloak/providers/keycloak-phone-provider.resources/pom.xml b/src/keycloak/providers/keycloak-phone-provider.resources/pom.xml new file mode 100644 index 000000000..8e15d12ad --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider.resources/pom.xml @@ -0,0 +1,42 @@ + + + 4.0.0 + + + cc.coopersoft + keycloak-phone-provider-parent + 2.3.4-snapshot + + + keycloak-phone-provider.resources + + + + + maven-dependency-plugin + + + package + + copy + + + + + ${project.groupId} + ${project.artifactId} + ${project.version} + + + ${project.basedir}/../jars + true + true + + + + + + + diff --git a/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/META-INF/keycloak-themes.json b/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/META-INF/keycloak-themes.json new file mode 100644 index 000000000..2fcdbdb32 --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/META-INF/keycloak-themes.json @@ -0,0 +1,8 @@ +{ + "themes": [ + { + "name": "phone", + "types": ["login", "email"] + } + ] +} diff --git a/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/account/account.ftl b/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/account/account.ftl new file mode 100644 index 000000000..2dfdf4be1 --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/account/account.ftl @@ -0,0 +1,197 @@ +<#import "template.ftl" as layout> +<@layout.mainLayout active='account' bodyClass='user'; section> + + + + + + + +
+
+

${msg("editAccountHtmlTitle")}

+
+
+ * ${msg("requiredFields")} +
+
+ +
+
+ +
+ + +
+ + + +
+
+ <#if realm.editUsernameAllowed>* +
+ +
+ disabled="disabled" value="${(account.username!'')}"/> +
+
+ +
+
+ * +
+ +
+ +
+
+ +
+
+ * +
+ +
+ +
+
+ +
+
+ * +
+ +
+ +
+
+ +
+
+ +
+ +
+ +
+
+ +
+
+ +
+ +
+ +
+ +
+ +
+
+ +
+
+
+
+
+
+ + + + +
+ + + + + \ No newline at end of file diff --git a/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/account/theme.properties b/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/account/theme.properties new file mode 100644 index 000000000..3e50437b9 --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/account/theme.properties @@ -0,0 +1,18 @@ +# +# Copyright 2016 Red Hat, Inc. and/or its affiliates +# and other contributors as indicated by the @author tags. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +parent=keycloak \ No newline at end of file diff --git a/src/keycloak/themes/yoma/email/html/email-update-confirmation.ftl b/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/email/html/email-update-confirmation.ftl similarity index 100% rename from src/keycloak/themes/yoma/email/html/email-update-confirmation.ftl rename to src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/email/html/email-update-confirmation.ftl diff --git a/src/keycloak/themes/yoma/email/html/email-verification.ftl b/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/email/html/email-verification.ftl similarity index 100% rename from src/keycloak/themes/yoma/email/html/email-verification.ftl rename to src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/email/html/email-verification.ftl diff --git a/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/email/html/otp.ftl b/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/email/html/otp.ftl new file mode 100644 index 000000000..5f7a59c38 --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/email/html/otp.ftl @@ -0,0 +1 @@ +

Hello ${user.firstName} ${user.lastName},

Your One-Time Password (OTP) is: ${otp}

Please use this OTP to complete your login or verification process.

If you did not request this OTP, please ignore this email.

Best regards,
Your Company

diff --git a/src/keycloak/themes/yoma/email/html/password-reset.ftl b/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/email/html/password-reset.ftl similarity index 100% rename from src/keycloak/themes/yoma/email/html/password-reset.ftl rename to src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/email/html/password-reset.ftl diff --git a/src/keycloak/themes/yoma/email/html/template.ftl b/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/email/html/template.ftl similarity index 100% rename from src/keycloak/themes/yoma/email/html/template.ftl rename to src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/email/html/template.ftl diff --git a/src/keycloak/themes/yoma/email/messages/messages_en.properties b/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/email/messages/messages_en.properties similarity index 100% rename from src/keycloak/themes/yoma/email/messages/messages_en.properties rename to src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/email/messages/messages_en.properties diff --git a/src/keycloak/themes/yoma/email/messages/messages_es.properties b/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/email/messages/messages_es.properties similarity index 100% rename from src/keycloak/themes/yoma/email/messages/messages_es.properties rename to src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/email/messages/messages_es.properties diff --git a/src/keycloak/themes/yoma/email/messages/messages_fr.properties b/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/email/messages/messages_fr.properties similarity index 100% rename from src/keycloak/themes/yoma/email/messages/messages_fr.properties rename to src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/email/messages/messages_fr.properties diff --git a/src/keycloak/themes/yoma/email/resources/img/icon-zlto.svg b/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/email/resources/img/icon-zlto.svg similarity index 100% rename from src/keycloak/themes/yoma/email/resources/img/icon-zlto.svg rename to src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/email/resources/img/icon-zlto.svg diff --git a/src/keycloak/themes/yoma/email/resources/img/yoma.png b/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/email/resources/img/yoma.png similarity index 100% rename from src/keycloak/themes/yoma/email/resources/img/yoma.png rename to src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/email/resources/img/yoma.png diff --git a/src/keycloak/themes/yoma/email/text/email-update-confirmation.ftl b/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/email/text/email-update-confirmation.ftl similarity index 100% rename from src/keycloak/themes/yoma/email/text/email-update-confirmation.ftl rename to src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/email/text/email-update-confirmation.ftl diff --git a/src/keycloak/themes/yoma/email/text/email-verification.ftl b/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/email/text/email-verification.ftl similarity index 100% rename from src/keycloak/themes/yoma/email/text/email-verification.ftl rename to src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/email/text/email-verification.ftl diff --git a/src/keycloak/themes/yoma/email/text/password-reset.ftl b/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/email/text/password-reset.ftl similarity index 100% rename from src/keycloak/themes/yoma/email/text/password-reset.ftl rename to src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/email/text/password-reset.ftl diff --git a/src/keycloak/themes/yoma/email/theme.properties b/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/email/theme.properties similarity index 100% rename from src/keycloak/themes/yoma/email/theme.properties rename to src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/email/theme.properties diff --git a/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/login/login-reset-password.ftl b/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/login/login-reset-password.ftl new file mode 100644 index 000000000..58a6456e5 --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/login/login-reset-password.ftl @@ -0,0 +1,227 @@ +<#import "template.ftl" as layout> +<@layout.registrationLayout displayInfo=true displayMessage=!messagesPerField.existsError('username','code','phoneNumber'); section> + <#if section = "header"> + ${msg("emailForgotTitle")} + <#elseif section = "form"> + + <#if supportPhone??> + + + + + + + + + +
+
+
+ <#if supportPhone??> +
+
+ +
+ + {{ errorMessage }} +
+ +
+ + + + +
v-if="!phoneActivated" > +
+ + + + <#if messagesPerField.existsError('username')> + + ${kcSanitize(messagesPerField.get('username'))?no_esc} + + +
+
+ + <#if supportPhone??> +
+
+ + + + + <#if messagesPerField.existsError('phoneNumber')> + + ${kcSanitize(messagesPerField.getFirstError('phoneNumber'))?no_esc} + + + + + +
+ +
+ + +
+ + + +
+
+ {{ messageSendCodeSuccess }} +
+
+ {{ messageSendCodeError }} +
+ <#if messagesPerField.existsError('code')> +
+ ${kcSanitize(messagesPerField.getFirstError('code'))?no_esc} +
+ +
+
+ + +
+ +
+ + + +
+
+ + <#if supportPhone??> + + + + <#-- <#elseif section = "info" > + <#if realm.duplicateEmailsAllowed> + ${msg("emailInstructionUsername")} + <#else> + ${msg("emailInstruction")} + + <#if supportPhone??> + ${msg("phoneInstruction")} + --> + + diff --git a/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/login/login-sms-otp-config.ftl b/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/login/login-sms-otp-config.ftl new file mode 100644 index 000000000..813f4e8c3 --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/login/login-sms-otp-config.ftl @@ -0,0 +1,108 @@ +<#import "template.ftl" as layout> +<@layout.registrationLayout displayInfo=true; section> + <#if section = "header"> + ${msg("configSms2Fa")} + <#elseif section = "form"> + + + + +
+
+
+ +
+ + {{ errorMessage }} +
+ + +
+
+
+
+
+ +
+
+ autofocus + value="${phoneNumber!''}" + autocomplete="mobile tel"/> +
+
+ +
+
+
+ + autofocus + autocomplete="off"/> +
+
+ value="${auth.selectedCredential}"/> + +
+
+
+
+
+ + + <#elseif section = "info"> + ${msg("configSms2FaInfo")} + + diff --git a/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/login/login-sms-otp.ftl b/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/login/login-sms-otp.ftl new file mode 100644 index 000000000..0e53f6dc7 --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/login/login-sms-otp.ftl @@ -0,0 +1,111 @@ +<#import "template.ftl" as layout> +<@layout.registrationLayout displayInfo=true; section> + <#if section = "header"> + ${msg("authCodePhoneNumber")} + <#elseif section = "form"> + + + + +
+
+
+ +
+ + {{ errorMessage }} +
+
+
+
+ + +
+
+ +
+
+ +
+
+ +
+
+ +
+ value="${auth.selectedCredential}"/> + +
+
+
+
+ +
+ + + <#elseif section = "info"> + ${msg("authCodeInfo")} + + diff --git a/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/login/login-update-password.ftl b/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/login/login-update-password.ftl new file mode 100644 index 000000000..2bd5b1416 --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/login/login-update-password.ftl @@ -0,0 +1,95 @@ +<#import "template.ftl" as layout> +<@layout.registrationLayout displayMessage=!messagesPerField.existsError('password','password-confirm'); section> + <#if section = "header"> +

${msg("updatePasswordTitle")}

+ <#elseif section = "form"> +
+
+ + + + <#-- Generate password --> +
+ + +
+ ${msg("createPasswordHelpText")} + +
+
+ +
+ + +
+ + +
+ + <#if messagesPerField.existsError('password')> + + ${kcSanitize(messagesPerField.get('password'))?no_esc} + + + +
+
+ +
+ + +
+ + + + <#if messagesPerField.existsError('password-confirm')> + + ${kcSanitize(messagesPerField.get('password-confirm'))?no_esc} + + +
+
+ +
+ +
+ +
+ <#if isAppInitiatedAction??> + + + <#else> + + +
+
+
+ + + + + + + + + + diff --git a/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/login/login-update-phone-number.ftl b/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/login/login-update-phone-number.ftl new file mode 100644 index 000000000..857bedab2 --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/login/login-update-phone-number.ftl @@ -0,0 +1,152 @@ +<#import "template.ftl" as layout> +<@layout.registrationLayout displayInfo=true; section> + <#if section = "header"> + ${msg("updatePhoneNumber")} + <#elseif section = "form"> + + + + + + +
+
+
+
+
+ + + + + <#if messagesPerField.existsError('phoneNumber')> + + ${kcSanitize(messagesPerField.getFirstError('phoneNumber'))?no_esc} + + + + +
+ +
+ + +
+ + + +
+
+ {{ messageSendCodeSuccess }} +
+
+ {{ messageSendCodeError }} +
+ <#if messagesPerField.existsError('code')> +
+ ${kcSanitize(messagesPerField.getFirstError('code'))?no_esc} +
+ +
+ +
+ value="${auth.selectedCredential}"/> + +
+
+
+
+
+ + + <#elseif section = "info"> + ${msg("updatePhoneNumberInfo")} + + diff --git a/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/login/login.ftl b/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/login/login.ftl new file mode 100644 index 000000000..c4548c6a0 --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/login/login.ftl @@ -0,0 +1,306 @@ +<#import "template.ftl" as layout> +<@layout.registrationLayout displayMessage=!messagesPerField.existsError('username','password','code','phoneNumber') displayInfo=realm.password && realm.registrationAllowed && !registrationDisabled??; section> + <#if section = "header"> + ${msg("loginAccountTitle")} + <#elseif section = "form"> + <#if !usernameHidden?? && supportPhone??> + + + + + + + + + + +
+
+ <#if realm.password> +
+ <#if !usernameHidden?? && supportPhone??> + + + + + + +
v-if="!phoneActivated" > + <#if !usernameHidden??> +
+ + +
+ <#if !usernameHidden??> + + +
+ +
+ +
+ + + <#if messagesPerField.existsError('username','phoneNumber','password')> + + ${kcSanitize(messagesPerField.getFirstError('username','phoneNumber','password'))?no_esc} + + + + + + +
+ + +
+ + +
+ + +
+ <#-- + --> + + <#if usernameHidden?? && messagesPerField.existsError('username','password')> + + ${kcSanitize(messagesPerField.getFirstError('username','password'))?no_esc} + + +
+
+ + <#if !usernameHidden?? && supportPhone??> +
+
+ + + + + <#if messagesPerField.existsError('phoneNumber')> + + ${kcSanitize(messagesPerField.getFirstError('phoneNumber'))?no_esc} + + + + + +
+ +
+ + +
+ + + +
+ +
+ {{ messageSendCodeSuccess }} +
+ +
+ {{ messageSendCodeError }} +
+ + <#if messagesPerField.existsError('code')> +
+ ${kcSanitize(messagesPerField.getFirstError('code'))?no_esc} +
+ +
+
+ + +
+
+ <#if realm.rememberMe && !usernameHidden??> +
+ +
+ +
+
+ +
+ value="${auth.selectedCredential}" /> + +
+ +
+
+ <#if realm.resetPasswordAllowed> + + +
+
+
+ +
+
+ + <#if !usernameHidden?? && supportPhone??> + + + <#elseif section = "info" > + <#if realm.password && realm.registrationAllowed && !registrationDisabled??> +
+
+ ${msg("noAccount")} + ${msg("doRegister")} +
+
+ + <#elseif section = "socialProviders" > + <#if realm.password && social.providers??> +
+
+

${msg("identity-provider-login-label")}

+ +
+ + + diff --git a/src/keycloak/themes/yoma/login/messages/messages_en.properties b/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/login/messages/messages_en.properties similarity index 90% rename from src/keycloak/themes/yoma/login/messages/messages_en.properties rename to src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/login/messages/messages_en.properties index d01f175c0..4a8c00b03 100644 --- a/src/keycloak/themes/yoma/login/messages/messages_en.properties +++ b/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/login/messages/messages_en.properties @@ -1,3 +1,24 @@ +updatePhoneNumber=Update Your Mobile Number +configSms2Fa=Configure SMS 2FA +authCodePhoneNumber=Authentication code +updatePhoneNumberInfo=Enter you mobile number and click to receive a code. This code proves that you own the number. +configSms2FaInfo=Enter you mobile number and click to receive a code. This code proves that you own the number. +authCodeInfo=Enter the authentication code you received on your mobile. +phoneNumber=Phone number +requiredPhoneNumber=Phone number is required. +sendVerificationCode=Send code +verificationCode=Verification code +authenticationCode=Authentication code +noOngoingVerificationProcess=There is no ongoing verification process. +verificationCodeDoesNotMatch=Informed verification code does not match ours. +phoneTokenCodeDoesNotMatch=Informed authentication code does not match ours. +abusedMessageService=Too many code requests for your mobile number. +sendVerificationCodeFail=Verification code send fail! +invalidPhoneNumber=Phone number is invalid +invalidPhoneNumberCountryCode=The country code supplied did not belong to a supported country or non-geographical entity. +invalidPhoneNumberTooLong=Phone number is invalid, too long\uFF01 +invalidPhoneNumberMustNumber=Phone number is invalid, must a number! +invalidPhoneNumberTooShort=Phone number is invalid, too short! doLogIn=Sign in doRegisterBtn=Sign up doRegister=New to Yoma? Join now @@ -24,7 +45,7 @@ bypassKerberosDetail=Either you are not logged in by Kerberos or your browser is kerberosNotSetUp=Kerberos is not set up. You cannot login. registerTitle=Sign up to Yoma registerSubTitle= -loginAccountTitle=Sign in to continue +loginAccountTitle=Sign in to Yoma loginTitle=Sign in to {0} loginSubTitle=Sign in with your details below loginTitleHtml={0} @@ -110,6 +131,7 @@ postal_code=Zip or Postal code country=Country emailVerified=Email verified website=Web page +phone=Phone phoneNumber=Phone number phoneNumberVerified=Phone number verified gender=Gender @@ -184,7 +206,7 @@ emailLinkIdp5=to continue. backToLogin=Sign in here backToLoginBtn=Already a member? Sign in -emailInstruction=Enter your username or email address and we will send you instructions on how to create a new password. +emailInstruction=Enter your email address and we will send you instructions on how to create a new password. emailInstructionUsername=Enter your username and we will send you instructions on how to create a new password. copyCodeInstruction=Please copy this code and paste it into your application: @@ -529,3 +551,37 @@ logoutConfirmHeader=Do you want to log out? doLogout=Logout readOnlyUsernameMessage=You can''t update your username as it is read-only. + +invalidPhoneNumberNotSupported=Phone number is invalid, not supported! + +phoneNumberExists=Phone number already registered +requiredVerificationCode=Verification code is required. +phoneInstruction=Use phone verification code reset password. +phoneUserNotFound=User does not exist. +codeSent=An OTP code has been sent to {0} +duplicatePhoneAllowedCantLogin = duplicate Phone is Allowed, so can''t use login by phone. change --spi-phone-support-default-<$realm>-duplicate-phone-allowed to false + +loginByPhone=SMS code +loginByPassword=Password + +usernameOrPhoneNumber=Username or phone +usernameOrEmailOrPhoneNumber=Username or email or phone +emailOrPhoneNumber=Email or phone +smsCodeMessage=\u005B{0}\u005D - {1} code: {2}, expires: {3} minute +phoneNumberAsUsername=Use phone as username +enterPhoneNumber=Enter phone number +enterEmail=Enter email +enterCode=Enter code +enterPassword=Enter password +confirmPassword=Confirm password +createPassword=Create my password +createPasswordHelpText=Suggest a strong password +changePhoneNumber=Change phone number +verifyCode=Verify code + +#password requirements +password_requirement_length=10 Characters Long +password_requirement_lowercase=1 lower case +password_requirement_uppercase=1 UPPER CASE +password_requirement_number=1 Numb3r +password_requirement_email=Different from email diff --git a/src/keycloak/themes/yoma/login/messages/messages_es.properties b/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/login/messages/messages_es.properties similarity index 85% rename from src/keycloak/themes/yoma/login/messages/messages_es.properties rename to src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/login/messages/messages_es.properties index 47090d9e5..c1075502a 100644 --- a/src/keycloak/themes/yoma/login/messages/messages_es.properties +++ b/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/login/messages/messages_es.properties @@ -17,7 +17,7 @@ bypassKerberosDetail=O bien no estás identificado mediante Kerberos o tu navega kerberosNotSetUp=Kerberos no está configurado. No puedes identificarte. registerWithTitle=Regístrate con {0} registerWithTitleHtml={0} -loginAccountTitle=Acceder a tu cuenta +loginAccountTitle=Iniciar sesión en Yoma loginTitle=Inicia sesión en {0} loginTitleHtml={0} impersonateTitle={0} Personificar Usuario @@ -69,7 +69,25 @@ region=Estado, Provincia, o Región postal_code=Código Postal country=País emailVerified=Email verificado +website=Web page +phone=Teléfono +phoneNumber=Teléfono +phoneNumberVerified=Teléfono verificado +gender=Género +birthday=Fecha de nacimiento +zoneinfo=Zona horaria gssDelegationCredential=GSS Delegation Credential +logoutOtherSessions=Cerrar otras sesiones + +profileScopeConsentText=Perfil de usuario +emailScopeConsentText=Dirección de correo electrónico +addressScopeConsentText=Dirección +phoneScopeConsentText=Número de teléfono +offlineAccessScopeConsentText=Acceso sin conexión +samlRoleListScopeConsentText=Mis roles +rolesScopeConsentText=Roles de usuario + +restartLoginTooltip=Reiniciar inicio de sesión loginTotpIntro=Es necesario configurar un generador de claves de un sólo uso para acceder a esta cuenta loginTotpStep1=Instala FreeOTP o Google Authenticator en tu teléfono móvil. Ambas aplicaciones están disponibles en Google Play y en la App Store de Apple. @@ -98,7 +116,7 @@ emailVerifyInstruction3=para reenviar el email. backToLogin=« Volver a la identificación -emailInstruction=Indica tu usuario o email y te enviaremos instrucciones indicando cómo generar una nueva contraseña. +emailInstruction=Ingrese su dirección de correo electrónico y le enviaremos instrucciones sobre cómo crear una nueva contraseña. copyCodeInstruction=Por favor, copia y pega este código en tu aplicación: @@ -261,3 +279,37 @@ updateEmailText=No se preocupe, podemos enviar el enlace de nuevo. passwordInstructions=La contrase\u00F1a debe tener al menos 8 caracteres y contener al menos una letra may\u00FAscula y un car\u00E1cter especial. backToLoginBtn=\u00BFYa eres miembro? Inicia sesi\u00F3n goBack=Volver a + +invalidPhoneNumberNotSupported=¡Número de teléfono no válido, no soportado! + +phoneNumberExists=El número de teléfono ya está registrado +requiredVerificationCode=Se requiere el código de verificación. +phoneInstruction=Utilice el código de verificación telefónica para restablecer la contraseña. +phoneUserNotFound=El usuario no existe. +codeSent=El código OTP se ha enviado a {0} +duplicatePhoneAllowedCantLogin = Se permite el teléfono duplicado, por lo que no se puede usar el inicio de sesión por teléfono. Cambie --spi-phone-support-default-<$realm>-duplicate-phone-allowed a false + +loginByPhone=Código SMS +loginByPassword=Contraseña + +usernameOrPhoneNumber=Nombre de usuario o teléfono +usernameOrEmailOrPhoneNumber=Nombre de usuario, correo electrónico o teléfono +emailOrPhoneNumber=Correo electrónico o teléfono +smsCodeMessage=\u005B{0}\u005D - {1} código: {2}, expira: {3} minutos +phoneNumberAsUsername=Usar número de teléfono como nombre de usuario +enterPhoneNumber=Ingrese su número de teléfono +enterEmail=Ingrese su correo electrónico +enterCode=Ingrese el código +enterPassword=Ingrese la contraseña +createPassword=Cree su contraseña +createPasswordHelpText=Se recomienda una contraseña segura +confirmPassword=Confirme la contraseña +changePhoneNumber=Cambiar número de teléfono +verifyCode=Verificar código + +#password requirements +password_requirement_length=10 caracteres de longitud +password_requirement_lowercase=1 minúscula +password_requirement_uppercase=1 MAYÚSCULA +password_requirement_number=1 número +password_requirement_email=Diferente del email diff --git a/src/keycloak/themes/yoma/login/messages/messages_fr.properties b/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/login/messages/messages_fr.properties similarity index 92% rename from src/keycloak/themes/yoma/login/messages/messages_fr.properties rename to src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/login/messages/messages_fr.properties index f0fc901ea..6efae0004 100644 --- a/src/keycloak/themes/yoma/login/messages/messages_fr.properties +++ b/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/login/messages/messages_fr.properties @@ -23,7 +23,7 @@ kerberosNotConfiguredTitle=Kerberos non configur\u00e9 bypassKerberosDetail=Si vous n''\u00eates pas connect\u00e9 via Kerberos ou bien que votre navigateur n''est pas configur\u00e9 pour la connexion via Kerberos. Veuillez cliquer pour vous connecter via un autre moyen. kerberosNotSetUp=Kerberos n''est pas configur\u00e9. Connexion impossible. registerTitle=S''enregistrer -loginAccountTitle=Connectez-vous \u00e0 votre compte +loginAccountTitle=Se connecter à Yoma registerWithTitle=Enregistrement avec {0} registerWithTitleHtml={0} loginTitle=Se connecter \u00e0 {0} @@ -96,6 +96,7 @@ postal_code=Code postal country=Pays emailVerified=Courriel v\u00e9rifi\u00e9 website=Page web +phone=Téléphone phoneNumber=Num\u00e9ro de t\u00e9l\u00e9phone phoneNumberVerified=Num\u00e9ro de t\u00e9l\u00e9phone v\u00e9rifi\u00e9 gender=Sexe @@ -164,8 +165,8 @@ emailLinkIdp5=pour continuer. backToLogin=« Retour \u00e0 la connexion -emailInstruction=Entrez votre nom d''utilisateur ou votre courriel ; un courriel va vous \u00eatre envoy\u00e9 vous permettant de cr\u00e9er un nouveau mot de passe. -emailInstructionUsername=Entrez votre nom d''utilisateur ; un courriel va vous \u00eatre envoy\u00e9 vous permettant de cr\u00e9er un nouveau mot de passe. +emailInstruction=Entrez votre adresse e-mail et nous vous enverrons des instructions sur la façon de créer un nouveau mot de passe. +emailInstructionUsername=Entrez votre nom d''utilisateur un courriel va vous \u00eatre envoy\u00e9 vous permettant de cr\u00e9er un nouveau mot de passe. copyCodeInstruction=Copiez le code et recopiez le dans votre application : @@ -443,3 +444,37 @@ updateEmailText=Pas de panique, nous pouvons renvoyer le lien. passwordInstructions=Le mot de passe doit comporter au moins 8 caract\u00E8res et contenir au moins une lettre majuscule et un caract\u00E8re sp\u00E9cial. backToLoginBtn=D\u00E9j\u00E0 membre ? Connectez-vous goBack=Retourner \u00E0 + +invalidPhoneNumberNotSupported=Le numéro de téléphone est invalide, non supporté ! + +phoneNumberExists=Numéro de téléphone déjà enregistré +requiredVerificationCode=Le code de vérification est requis. +phoneInstruction=Utilisez le code de vérification par téléphone pour réinitialiser le mot de passe. +phoneUserNotFound=L''utilisateur n''existe pas. +codeSent=Le code OTP a été envoyé à {0} +duplicatePhoneAllowedCantLogin=Le téléphone en double est autorisé, donc la connexion par téléphone n''est pas possible. Changez --spi-phone-support-default-<$realm>-duplicate-phone-allowed à false + +loginByPhone=Code SMS +loginByPassword=Mot de passe + +usernameOrPhoneNumber=Nom d''utilisateur ou téléphone +usernameOrEmailOrPhoneNumber=Nom d''utilisateur ou email ou téléphone +emailOrPhoneNumber=Email ou téléphone +smsCodeMessage=\u005B{0}\u005D - {1} code : {2}, expire dans : {3} minute +phoneNumberAsUsername=Utiliser le numéro de téléphone comme nom d''utilisateur +enterPhoneNumber=Entrez votre numéro de téléphone +enterEmail=Entrez votre adresse e-mail +enterCode=Entrez le code +enterPassword=Entrez le mot de passe +confirmPassword=Confirmez le mot de passe +createPassword=Créer un mot de passe +createPasswordHelpText=Créez un mot de passe fort +changePhoneNumber=Changer le numéro de téléphone +verifyCode=Vérifier le code + +#password requirements +password_requirement_length=10 caractères minimum +password_requirement_lowercase=1 minuscule +password_requirement_uppercase=1 MAJUSCULE +password_requirement_number=1 chiffre +password_requirement_email=Différent de l''email diff --git a/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/login/register-user-profile.ftl b/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/login/register-user-profile.ftl new file mode 100644 index 000000000..af72af0c7 --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/login/register-user-profile.ftl @@ -0,0 +1,304 @@ +<#import "template.ftl" as layout> +<@layout.registrationLayout displayMessage=!messagesPerField.existsError('firstName','lastName','email','username','password','password-confirm','phoneNumber','code'); section> + <#if section="header"> + ${msg("registerTitle")} + <#elseif section="form"> + + + + + + + + + + +
+
+ +
+ +
+ + + + +
+ + + + <#if messagesPerField.existsError('email')> + + ${kcSanitize(messagesPerField.get('email'))?no_esc} + + +
+ + +
+ + + + <#if messagesPerField.existsError('phoneNumber')> + + ${kcSanitize(messagesPerField.getFirstError('phoneNumber'))?no_esc} + + + + + + +
+ + <#if verifyPhone??> +
+ +
+ + +
+ + + +
+
+ {{ messageSendCodeSuccess }} +
+
+ {{ messageSendCodeError }} +
+ <#if messagesPerField.existsError('code')> +
+ ${kcSanitize(messagesPerField.getFirstError('code'))?no_esc} +
+ +
+ + +
+
+ +
+
+
+ + + + <#if passwordRequired??> + <#-- Generate password --> +
+ + +
+ ${msg("createPasswordHelpText")} + +
+
+ +
+ + +
+ + +
+ + <#if messagesPerField.existsError('password')> + + ${kcSanitize(messagesPerField.get('password'))?no_esc} + + + +
+
+ +
+ + +
+ + +
+ + <#if messagesPerField.existsError('password-confirm')> + + ${kcSanitize(messagesPerField.get('password-confirm'))?no_esc} + + +
+ + + +
+
+ + +
+
+ + + <#if recaptchaRequired??> +
+
+
+
+
+ + + +
+ +
+ +
+
+ + + + diff --git a/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/login/resources/css/passwordIndicator.css b/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/login/resources/css/passwordIndicator.css new file mode 100644 index 000000000..694c35b4e --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/login/resources/css/passwordIndicator.css @@ -0,0 +1,15 @@ +.password-requirements { + flex-direction: column; + display: none; + color: #72767b; +} + +.requirement-success { + text-decoration: underline; + color: green; +} + +.requirement-fail { + text-decoration: underline; + color: red; +} diff --git a/src/keycloak/themes/yoma/login/resources/css/styles.css b/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/login/resources/css/styles.css similarity index 76% rename from src/keycloak/themes/yoma/login/resources/css/styles.css rename to src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/login/resources/css/styles.css index 7181453c6..86466fa70 100644 --- a/src/keycloak/themes/yoma/login/resources/css/styles.css +++ b/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/login/resources/css/styles.css @@ -1,3 +1,10 @@ +/* General Layout */ +body { + padding: 0; + margin: 0 auto; + width: 96%; +} + .kc-locale-wrapper { margin-top: -80px; } @@ -8,13 +15,9 @@ right: 20px; } -/* #kc-locale-dropdown { -} */ - .login-pf-page .login-pf-page-header::before { content: ""; display: inline-block; - background-image: url("https://yoma-v3-public-storage.s3.eu-west-1.amazonaws.com/shared-resources/yoma.png"); width: 100px; height: 42px; margin-right: 10px; @@ -23,10 +26,6 @@ overflow: hidden; } -#logout-sessions { - display: hidden; -} - #kc-current-locale-link { text-decoration: none; font-size: 14px; @@ -36,12 +35,11 @@ } #kc-current-locale-link::after { - /* content: "▶" !important; */ - content: "" !important; + content: ""; color: #461d4d; font-size: 14px; display: inline-block; - transform: rotate(90deg) !important; + transform: rotate(90deg); } .pf-c-dropdown__menu { @@ -57,10 +55,10 @@ color: black; } +/* Forms and Inputs */ .login-pf, .login-pf body { background: none; - /* background-image: linear-gradient(to right, rgb(249, 171, 63) 50%, #f5f6f8 50%); */ background-color: rgb(249, 171, 63); color: #0a0c0d; } @@ -75,45 +73,33 @@ h1, margin-top: 20px; } +.form-horizontal { + display: flex; + flex-direction: column; + gap: 10px; + padding: 0 20px; +} .form-group:has(> div > input#terms_and_conditions) { display: none; } -#password-instructions { - width: 85%; - color: #72767b; - margin-inline: auto; - padding-left: 2px; - margin-bottom: 0.5rem; -} - -/* #toggle-password, -#toggle-password-confirm { -} */ - -#register-password, -#password-confirm, -#password-new { - padding-left: calc(1px + 10%); -} - -.password-container, -.password-confirm-container { +.password-container { position: relative; } -.password-container i, -.password-confirm-container i { + +.password-container i { position: absolute; top: 50%; - left: 50px; + left: 10px; transform: translateY(-50%); cursor: pointer; } -.fa-eye-slash::before { - font-size: 20px; +.password-container input { + padding-left: 40px !important; } +.fa-eye-slash::before, .fa-eye::before { font-size: 20px; } @@ -125,6 +111,7 @@ h1, #terms-label { margin-block: auto; margin-top: 5px; + font-size: 12px; } #terms { @@ -134,12 +121,13 @@ h1, #terms-prefix { color: #495057; - font-size: 14px; + font-size: 12px; margin-left: 5px; } #terms-text { display: inline; + font-size: 12px; } #email-icon { @@ -222,7 +210,6 @@ h1, #kc-info-wrapper { padding: 15px 0; font-size: 16px; - width: 95%; margin: 10px auto 0 auto; display: flex; flex-direction: column; @@ -233,14 +220,12 @@ h1, } .pf-c-button.pf-m-primary { - font-size: 13px; + font-size: 16px; background-color: #461d4d; color: white; border-radius: 50px; cursor: pointer; - font-size: 16px; height: 48px; - width: 85%; margin: 0 auto; display: block; } @@ -264,7 +249,6 @@ h1, border-radius: 50px; height: 48px; margin: 0 auto; - width: 90%; position: relative; line-height: 1.8; color: #461d4d; @@ -274,7 +258,7 @@ h1, color: #461d4d; font-size: 16px; margin-top: 8px; - outline: none !important; + outline: none; } p { @@ -282,7 +266,6 @@ p { font-size: 1rem; } -/* Hack to add divider above the register button, there is a DOM ghost stealing the hr element */ #kc-registration::before { content: ""; position: absolute; @@ -302,7 +285,7 @@ p { } .login-pf-page .login-pf-signup a { - margin: 0 0 0 0; + margin: 0; } .pf-c-button.pf-m-primary:hover { @@ -318,16 +301,14 @@ p { } .pf-c-form-control:not(textarea) { - font-size: 13px; + font-size: 14px; background-color: white; color: #495057; border-radius: 5px; - font-size: 14px; height: 48px; - width: 85%; margin: 0 auto; display: block; - border: 2px solid #e6e8eb !important; + border: 2px solid #e6e8eb; padding: 0 16px; } @@ -347,8 +328,7 @@ a { .grey-hr { border: none; border-top: 1px solid #cccccc; - margin: 22px auto 22px auto; - width: 85%; + margin: 0 22px; } #kc-register-form div { @@ -365,8 +345,6 @@ a { .login-pf body { color: #495057; text-align: left; - padding: 15px 35px; - margin-left: 10px; } .centered-div { @@ -393,21 +371,19 @@ a { } .pf-c-form__label-text { - font-size: 12px; - color: black; - margin: 0 0 5px 35px; + font-size: 13.4px; + color: #333333; } -.pf-c-form__helper-text { - margin: 0 0 0 35px; -} +/* .pf-c-form__helper-text { + color: #72767b; +} */ .centered-checkbox { display: flex; flex-direction: row; justify-content: center; align-items: center; - padding-inline: 2rem; gap: 10px; } @@ -448,9 +424,8 @@ a { flex-direction: row; justify-content: center; align-items: center; - width: 85%; - margin: 25px auto; } + .checkbox-update-password-label { display: flex; flex-direction: row; @@ -465,15 +440,72 @@ input[type="checkbox"] { width: 20px; } +.radio-wrapper { + display: flex; + justify-content: space-between; + align-items: center; +} + +/* Miscellaneous */ +.nav-pills { + display: flex; +} + +.nav-pills > li { + flex: 1; +} + +.nav-pills > li.active > a, +.nav-pills > li.active > a:focus, +.nav-pills > li.active > a:hover { + background-color: #461d4d; + color: white; + border-radius: 8px; +} + +.underline { + text-decoration: underline; + color: blue; + cursor: pointer; +} + +.iti { + width: 100%; +} + +.iti__search-input { + padding: 8px; +} + +/* Style the checkboxes */ +.checkbox { + display: inline-block; + position: relative; + padding-left: 25px; + margin-bottom: 12px; + cursor: pointer; + font-size: 22px; + user-select: none; +} + +.success-message { + color: green; +} + +.success-message .icon { + margin-right: 5px; +} + +/* Media Queries */ @media screen and (max-width: 767px) { body { - padding: 0 0 !important; - margin: 0 auto !important; - width: 96% !important; + padding: 0; + margin: 0 auto; + width: 96%; } .card-pf { - margin: 30px 10px !important; - padding: 25px 0 25px 0 !important; + margin: 30px 10px; + padding: 25px 0; border-radius: 0; display: flex; flex-direction: column; @@ -483,30 +515,12 @@ input[type="checkbox"] { margin-left: 0; } - #password-instructions { - padding-left: 0.5rem; - text-align: left !important; - display: block; - margin-bottom: 0.5rem; - } - - .fa-eye-slash::before { - margin-left: -0.5rem !important; + .password-container i { + left: 20px; } + .fa-eye-slash::before, .fa-eye::before { - margin-left: -0.5rem !important; - } - - #register-password, - #password-confirm, - #password-new { - padding-left: calc(1px + 15%); - } -} - -@media screen and (max-width: 375) { - #password-instructions { - padding-left: 0.7rem; + margin-left: -0.5rem; } } diff --git a/src/keycloak/themes/yoma/login/resources/img/email-icon.png b/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/login/resources/img/email-icon.png similarity index 100% rename from src/keycloak/themes/yoma/login/resources/img/email-icon.png rename to src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/login/resources/img/email-icon.png diff --git a/src/keycloak/themes/yoma/login/resources/img/favicon.ico b/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/login/resources/img/favicon.ico similarity index 100% rename from src/keycloak/themes/yoma/login/resources/img/favicon.ico rename to src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/login/resources/img/favicon.ico diff --git a/src/keycloak/themes/yoma/login/resources/img/icon-check.png b/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/login/resources/img/icon-check.png similarity index 100% rename from src/keycloak/themes/yoma/login/resources/img/icon-check.png rename to src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/login/resources/img/icon-check.png diff --git a/src/keycloak/themes/yoma/login/resources/img/icon-cross.png b/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/login/resources/img/icon-cross.png similarity index 100% rename from src/keycloak/themes/yoma/login/resources/img/icon-cross.png rename to src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/login/resources/img/icon-cross.png diff --git a/src/keycloak/themes/yoma/login/resources/img/logo.svg b/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/login/resources/img/logo.svg similarity index 100% rename from src/keycloak/themes/yoma/login/resources/img/logo.svg rename to src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/login/resources/img/logo.svg diff --git a/src/keycloak/themes/yoma/login/resources/img/yoma.png b/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/login/resources/img/yoma.png similarity index 100% rename from src/keycloak/themes/yoma/login/resources/img/yoma.png rename to src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/login/resources/img/yoma.png diff --git a/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/login/resources/js/intlTelInputDirective.js b/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/login/resources/js/intlTelInputDirective.js new file mode 100644 index 000000000..234b6bd9b --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/login/resources/js/intlTelInputDirective.js @@ -0,0 +1,15 @@ +Vue.directive("intl-tel-input", { + inserted(el) { + intlTelInput(el, { + loadUtilsOnInit: "https://cdn.jsdelivr.net/npm/intl-tel-input@24.6.0/build/js/utils.js", + onlyCountries: ["za"], // only South Africa for now + initialCountry: "auto", + geoIpLookup: (callback) => { + fetch("https://ipapi.co/json") + .then((res) => res.json()) + .then((data) => callback(data.country_code)) + .catch(() => callback("za")); + }, + }); + }, +}); diff --git a/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/login/resources/js/passwordGeneratorDirective.js b/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/login/resources/js/passwordGeneratorDirective.js new file mode 100644 index 000000000..e5298b182 --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/login/resources/js/passwordGeneratorDirective.js @@ -0,0 +1,46 @@ +Vue.directive("password-generator", { + bind(el, binding) { + const { passwordSelector, confirmPasswordSelector } = binding.value; + + const generateValidPassword = () => { + const length = 10; + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + let password = ""; + while (!isValidPassword(password)) { + password = ""; + const randomValues = new Uint32Array(length); + window.crypto.getRandomValues(randomValues); + for (let i = 0; i < length; i++) { + const randomIndex = randomValues[i] % charset.length; + password += charset[randomIndex]; + } + } + return password; + }; + + const isValidPassword = (password) => { + const hasUpperCase = /[A-Z]/.test(password); + const hasLowerCase = /[a-z]/.test(password); + const hasNumber = /\d/.test(password); + return password.length >= 10 && hasUpperCase && hasLowerCase && hasNumber; + }; + + el.addEventListener("change", () => { + if (el.checked) { + const generatedPassword = generateValidPassword(); + const passwordInput = document.querySelector(passwordSelector); + const confirmPasswordInput = document.querySelector(confirmPasswordSelector); + + if (passwordInput) { + passwordInput.value = generatedPassword; + passwordInput.dispatchEvent(new Event("input")); // Trigger input event for v-model binding + } + + if (confirmPasswordInput) { + confirmPasswordInput.value = generatedPassword; + confirmPasswordInput.dispatchEvent(new Event("input")); // Trigger input event for v-model binding + } + } + }); + }, +}); diff --git a/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/login/resources/js/passwordIndicatorDirective.js b/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/login/resources/js/passwordIndicatorDirective.js new file mode 100644 index 000000000..041c7e56e --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/login/resources/js/passwordIndicatorDirective.js @@ -0,0 +1,45 @@ +Vue.directive("password-indicator", { + inserted(el, binding) { + const { resourcesPath, /*emailSelector,*/ passwordSelector, labels } = binding.value; + + function updatePasswordIndicator() { + //const email = document.querySelector(emailSelector).value; + const password = document.querySelector(passwordSelector).value; + + const requirements = { + length: password.length >= 10, + lowercase: /[a-z]/.test(password), + uppercase: /[A-Z]/.test(password), + number: /\d/.test(password), + // email: !email || !password.includes(email), + }; + + const passwordRequirements = el; + const requirementText = ` + Password should be a minimum of ${labels.length} and must include at least + ${labels.uppercase}, ${labels.lowercase} and + ${labels.number}. + `; + passwordRequirements.innerHTML = requirementText; + + let allRequirementsMet = true; + + for (const requirement in requirements) { + const element = passwordRequirements.querySelector(`#${requirement}`); + if (requirements[requirement]) { + element.classList.add("requirement-success"); + element.classList.remove("requirement-fail"); + } else { + element.classList.add("requirement-fail"); + element.classList.remove("requirement-success"); + allRequirementsMet = false; + } + } + + passwordRequirements.style.display = allRequirementsMet ? "none" : "block"; + } + + document.querySelector(passwordSelector).addEventListener("input", updatePasswordIndicator); + // document.querySelector(emailSelector).addEventListener("input", updatePasswordIndicator); + }, +}); diff --git a/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/login/resources/js/togglePasswordDirective.js b/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/login/resources/js/togglePasswordDirective.js new file mode 100644 index 000000000..4a26ce4e1 --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/login/resources/js/togglePasswordDirective.js @@ -0,0 +1,17 @@ +Vue.directive("toggle-password", { + bind(el, binding) { + const { passwordSelector } = binding.value; + + el.addEventListener("click", () => { + const password = document.querySelector(passwordSelector); + + if (password.type === "password") { + password.type = "text"; + el.className = "fa fa-eye"; + } else { + password.type = "password"; + el.className = "fa fa-eye-slash"; + } + }); + }, +}); diff --git a/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/login/theme.properties b/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/login/theme.properties new file mode 100644 index 000000000..3f5c12103 --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/login/theme.properties @@ -0,0 +1,20 @@ +# +# Copyright 2016 Red Hat, Inc. and/or its affiliates +# and other contributors as indicated by the @author tags. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +parent=keycloak +meta=viewport==width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=no HandheldFriendly==true +styles=web_modules/@patternfly/react-core/dist/styles/base.css web_modules/@patternfly/react-core/dist/styles/app.css node_modules/patternfly/dist/css/patternfly.min.css node_modules/patternfly/dist/css/patternfly-additions.min.css css/login.css css/styles.css diff --git a/src/keycloak/themes/yoma/login/user-profile-commons.ftl b/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/login/user-profile-commons.ftl similarity index 76% rename from src/keycloak/themes/yoma/login/user-profile-commons.ftl rename to src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/login/user-profile-commons.ftl index fa528e012..71543578a 100644 --- a/src/keycloak/themes/yoma/login/user-profile-commons.ftl +++ b/src/keycloak/providers/keycloak-phone-provider.resources/src/main/resources/theme/phone/login/user-profile-commons.ftl @@ -30,28 +30,40 @@ - <#nested "beforeField" attribute> -
-
- - <#if attribute.required>* -
-
- <#if attribute.annotations.inputHelperTextBefore??> -
${kcSanitize(advancedMsg(attribute.annotations.inputHelperTextBefore))?no_esc}
- - <@inputFieldByType attribute=attribute/> - <#if messagesPerField.existsError('${attribute.name}')> - - ${kcSanitize(messagesPerField.get('${attribute.name}'))?no_esc} - - - <#if attribute.annotations.inputHelperTextAfter??> -
${kcSanitize(advancedMsg(attribute.annotations.inputHelperTextAfter))?no_esc}
- -
-
- <#nested "afterField" attribute> + + <#if attribute.name == "username" && (realm.registrationEmailAsUsername || registrationPhoneNumberAsUsername??)> + + <#elseif attribute.name == "email" && hideEmail??> + + <#elseif attribute.name == "phoneNumber"> + + <#elseif attribute.name == "terms_and_conditions"> + + <#else> + + <#nested "beforeField" attribute> +
+
+ + <#if attribute.required>* +
+
+ <#if attribute.annotations.inputHelperTextBefore??> +
${kcSanitize(advancedMsg(attribute.annotations.inputHelperTextBefore))?no_esc}
+ + <@inputFieldByType attribute=attribute/> + <#if messagesPerField.existsError('${attribute.name}')> + + ${kcSanitize(messagesPerField.get('${attribute.name}'))?no_esc} + + + <#if attribute.annotations.inputHelperTextAfter??> +
${kcSanitize(advancedMsg(attribute.annotations.inputHelperTextAfter))?no_esc}
+ +
+
+ <#nested "afterField" attribute> + diff --git a/src/keycloak/providers/keycloak-phone-provider/pom.xml b/src/keycloak/providers/keycloak-phone-provider/pom.xml new file mode 100644 index 000000000..168a5d76e --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider/pom.xml @@ -0,0 +1,80 @@ + + + 4.0.0 + + + cc.coopersoft + keycloak-phone-provider-parent + 2.3.4-snapshot + + + keycloak-phone-provider + + + + org.keycloak + keycloak-model-jpa + ${version.keycloak} + provided + + + com.googlecode.libphonenumber + libphonenumber + 8.13.7 + + + com.squareup.okhttp3 + okhttp + 4.10.0 + + + + + + + maven-assembly-plugin + + + package + + single + + + + + + jar-with-dependencies + + ${project.build.finalName} + false + + + + maven-dependency-plugin + + + package + + copy + + + + + ${project.groupId} + ${project.artifactId} + ${project.version} + + + ${project.basedir}/../jars + true + true + + + + + + + + diff --git a/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/common/OptionalUtils.java b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/common/OptionalUtils.java new file mode 100644 index 000000000..00ab66bae --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/common/OptionalUtils.java @@ -0,0 +1,21 @@ +package cc.coopersoft.common; + +import org.keycloak.services.validation.Validation; + +import java.util.Optional; +import java.util.regex.Pattern; + +public class OptionalUtils { + + public static Optional ofEmpty(String str){ + return Validation.isEmpty(str) ? Optional.empty() : Optional.of(str); + } + + public static Optional ofBlank(String str){ + return Validation.isBlank(str) ? Optional.empty() : Optional.of(str).map(String::trim); + } + + public static Optional ofTrue(boolean b){ + return b ? Optional.of(true) : Optional.empty(); + } +} diff --git a/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/Utils.java b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/Utils.java new file mode 100644 index 000000000..e98439525 --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/Utils.java @@ -0,0 +1,136 @@ +package cc.coopersoft.keycloak.phone; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Optional; +import java.util.Set; +import java.util.function.Predicate; +import java.util.regex.Pattern; + +import org.jboss.logging.Logger; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; + +import com.google.i18n.phonenumbers.NumberParseException; +import com.google.i18n.phonenumbers.PhoneNumberUtil; +import com.google.i18n.phonenumbers.PhoneNumberUtil.PhoneNumberFormat; + +import cc.coopersoft.common.OptionalUtils; +import cc.coopersoft.keycloak.phone.providers.exception.PhoneNumberInvalidException; +import cc.coopersoft.keycloak.phone.providers.spi.PhoneProvider; +import jakarta.validation.constraints.NotNull; + +public class Utils { + + private static final Logger logger = Logger.getLogger(Utils.class); + + public static Optional findUserByPhone(KeycloakSession session, RealmModel realm, String phoneNumber) { + + var userProvider = session.users(); + Set numbers = new HashSet<>(); + numbers.add(phoneNumber); + + if (session.getProvider(PhoneProvider.class).compatibleMode()) { + var phoneNumberUtil = PhoneNumberUtil.getInstance(); + try { + var parsedNumber = phoneNumberUtil.parse(phoneNumber, defaultRegion(session)); + if (parsedNumber.hasNationalNumber()) { + numbers.add(String.valueOf(parsedNumber.getNationalNumber())); + } + for (PhoneNumberFormat format : PhoneNumberFormat.values()) { + numbers.add(phoneNumberUtil.format(parsedNumber, format)); + } + } catch (NumberParseException e) { + logger.warn(String.format("%s is not a valid phone number!", phoneNumber), e); + } + } + + return numbers.stream().flatMap(number -> userProvider + .searchForUserByUserAttributeStream(realm, "phoneNumber", number)) + .max((u1, u2) -> { + var result = comparatorAttributesAnyMatch(u1, u2, "phoneNumberVerified", "true"::equals); + if (result == 0) { + result = comparatorAttributesAnyMatch(u1, u2, "phoneNumber", number -> number.startsWith("+")); + } + return result; + }); + + } + +// public static Optional findUserByPhone(UserProvider userProvider, RealmModel realm, String phoneNumber, String notIs){ +// return userProvider +// .searchForUserByUserAttributeStream(realm, "phoneNumber", phoneNumber) +// .filter(u -> !u.getId().equals(notIs)) +// .max(comparatorUser()); +// } + private static int comparatorAttributesAnyMatch(UserModel user1, UserModel user2, + String attribute, Predicate predicate) { + return Boolean.compare(user1.getAttributeStream(attribute).anyMatch(predicate), + user2.getAttributeStream(attribute).anyMatch(predicate)); + } + + private static Optional localeToCountry(String locale) { + return OptionalUtils.ofBlank(locale).flatMap(l -> { + Pattern countryRegx = Pattern.compile("[^a-z]*\\-?([A-Z]{2,3})"); + return Optional.of(countryRegx.matcher(l)) + .flatMap(m -> m.find() ? OptionalUtils.ofBlank(m.group(1)) : Optional.empty()); + }); + } + + private static String defaultRegion(KeycloakSession session) { + var defaultRegion = session.getProvider(PhoneProvider.class).defaultPhoneRegion(); + return defaultRegion.orElseGet(() -> localeToCountry(session.getContext().getRealm().getDefaultLocale()).orElse(null)); + } + + /** + * Parses a phone number with google's libphonenumber and then outputs it's + * international canonical form + * + */ + public static String canonicalizePhoneNumber(KeycloakSession session, @NotNull String phoneNumber) throws PhoneNumberInvalidException { + var provider = session.getProvider(PhoneProvider.class); + + var phoneNumberUtil = PhoneNumberUtil.getInstance(); + var resultPhoneNumber = phoneNumber.trim(); + var defaultRegion = defaultRegion(session); + logger.info(String.format("default region '%s' will be used", defaultRegion)); + try { + var parsedNumber = phoneNumberUtil.parse(resultPhoneNumber, defaultRegion); + if (provider.validPhoneNumber() && !phoneNumberUtil.isValidNumber(parsedNumber)) { + logger.info(String.format("Phone number [%s] Valid fail with google's libphonenumber", resultPhoneNumber)); + throw new PhoneNumberInvalidException(PhoneNumberInvalidException.ErrorType.VALID_FAIL, + String.format("Phone number [%s] Valid fail with google's libphonenumber", resultPhoneNumber)); + } + + var canonicalizeFormat = provider.canonicalizePhoneNumber(); + try { + resultPhoneNumber = canonicalizeFormat + .map(PhoneNumberFormat::valueOf) + .map(format -> phoneNumberUtil.format(parsedNumber, format)) + .orElse(resultPhoneNumber); + } catch (RuntimeException e) { + logger.warn(String.format("canonicalize format param error! '%s' is not in supported list: %s, E164 Will be used.", + Arrays.toString(PhoneNumberFormat.values()), + canonicalizeFormat.orElse("")), e); + resultPhoneNumber = phoneNumberUtil.format(parsedNumber, PhoneNumberFormat.E164); + } + + var phoneNumberRegex = provider.phoneNumberRegex(); + if (!phoneNumberRegex.map(resultPhoneNumber::matches).orElse(true)) { + logger.info(String.format("Phone number [%s] not match regex '%s'", resultPhoneNumber, phoneNumberRegex.orElse(""))); + throw new PhoneNumberInvalidException(PhoneNumberInvalidException.ErrorType.NOT_SUPPORTED, + String.format("Phone number [%s] not match regex '%s'", resultPhoneNumber, phoneNumberRegex.orElse(""))); + } + return resultPhoneNumber; + } catch (NumberParseException e) { + logger.info(e); + throw new PhoneNumberInvalidException(e); + } + } + + public static int getOtpExpires(KeycloakSession session) { + return session.getProvider(PhoneProvider.class).otpExpires(); + } + +} diff --git a/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/authentication/authenticators/browser/PhoneUsernamePasswordForm.java b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/authentication/authenticators/browser/PhoneUsernamePasswordForm.java new file mode 100644 index 000000000..9710007b8 --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/authentication/authenticators/browser/PhoneUsernamePasswordForm.java @@ -0,0 +1,413 @@ +package cc.coopersoft.keycloak.phone.authentication.authenticators.browser; + +import java.util.List; +import java.util.Objects; + +import org.jboss.logging.Logger; +import org.keycloak.Config; +import org.keycloak.authentication.AuthenticationFlowContext; +import org.keycloak.authentication.AuthenticationFlowError; +import org.keycloak.authentication.Authenticator; +import org.keycloak.authentication.AuthenticatorFactory; +import org.keycloak.authentication.authenticators.browser.AbstractUsernameFormAuthenticator; +import org.keycloak.authentication.authenticators.browser.UsernamePasswordForm; +import static org.keycloak.authentication.authenticators.util.AuthenticatorUtils.getDisabledByBruteForceEventError; +import org.keycloak.events.Details; +import org.keycloak.events.Errors; +import org.keycloak.forms.login.LoginFormsProvider; +import org.keycloak.models.AuthenticationExecutionModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.models.ModelDuplicateException; +import org.keycloak.models.UserModel; +import org.keycloak.models.credential.PasswordCredentialModel; +import org.keycloak.models.utils.FormMessage; +import org.keycloak.models.utils.KeycloakModelUtils; +import org.keycloak.provider.ProviderConfigProperty; +import static org.keycloak.provider.ProviderConfigProperty.BOOLEAN_TYPE; +import org.keycloak.provider.ProviderConfigurationBuilder; +import org.keycloak.services.ServicesLogger; +import org.keycloak.services.managers.AuthenticationManager; +import org.keycloak.services.messages.Messages; +import org.keycloak.services.validation.Validation; +import static org.keycloak.services.validation.Validation.FIELD_USERNAME; + +import cc.coopersoft.common.OptionalUtils; +import cc.coopersoft.keycloak.phone.Utils; +import cc.coopersoft.keycloak.phone.authentication.forms.SupportPhonePages; +import static cc.coopersoft.keycloak.phone.authentication.forms.SupportPhonePages.ATTEMPTED_PHONE_ACTIVATED; +import static cc.coopersoft.keycloak.phone.authentication.forms.SupportPhonePages.ATTEMPTED_PHONE_NUMBER; +import static cc.coopersoft.keycloak.phone.authentication.forms.SupportPhonePages.ATTEMPTED_PHONE_NUMBER_AS_USERNAME; +import static cc.coopersoft.keycloak.phone.authentication.forms.SupportPhonePages.ATTRIBUTE_SUPPORT_PHONE; +import static cc.coopersoft.keycloak.phone.authentication.forms.SupportPhonePages.FIELD_PATH_PHONE_ACTIVATED; +import static cc.coopersoft.keycloak.phone.authentication.forms.SupportPhonePages.FIELD_PHONE_NUMBER; +import static cc.coopersoft.keycloak.phone.authentication.forms.SupportPhonePages.FIELD_PHONE_NUMBER_AS_USERNAME; +import static cc.coopersoft.keycloak.phone.authentication.forms.SupportPhonePages.FIELD_VERIFICATION_CODE; +import cc.coopersoft.keycloak.phone.providers.constants.TokenCodeType; +import cc.coopersoft.keycloak.phone.providers.exception.PhoneNumberInvalidException; +import cc.coopersoft.keycloak.phone.providers.spi.PhoneVerificationCodeProvider; +import jakarta.ws.rs.core.MultivaluedMap; +import jakarta.ws.rs.core.Response; + +public class PhoneUsernamePasswordForm extends UsernamePasswordForm implements Authenticator, AuthenticatorFactory { + + private static final Logger logger = Logger.getLogger(PhoneUsernamePasswordForm.class); + + public static final String PROVIDER_ID = "auth-phone-username-password-form"; + + public static final String VERIFIED_PHONE_NUMBER = "LOGIN_BY_PHONE_VERIFY"; + + private static final String CONFIG_IS_LOGIN_WITH_PHONE_VERIFY = "loginWithPhoneVerify"; + + private static final String CONFIG_IS_LOGIN_WITH_PHONE_NUMBER = "loginWithPhoneNumber"; + + /** + * use phone and password login + * + * @param context + * @return + */ + private boolean isLoginWithPhoneNumber(AuthenticationFlowContext context) { + return context.getAuthenticatorConfig() == null + || context.getAuthenticatorConfig().getConfig().getOrDefault(CONFIG_IS_LOGIN_WITH_PHONE_NUMBER, "true").equals("true"); + } + + /** + * use phone and verify code login + * + * @param context + * @return + */ + private boolean isSupportPhone(AuthenticationFlowContext context) { + return context.getAuthenticatorConfig() == null + || context.getAuthenticatorConfig().getConfig().getOrDefault(CONFIG_IS_LOGIN_WITH_PHONE_VERIFY, "true").equals("true"); + } + + private LoginFormsProvider assemblyForm(AuthenticationFlowContext context, LoginFormsProvider form) { + if (isSupportPhone(context)) { + form.setAttribute(ATTRIBUTE_SUPPORT_PHONE, true); + } + if (isLoginWithPhoneNumber(context)) { + form.setAttribute("loginWithPhoneNumber", true); + } + + // Write out KC_HTTP_RELATIVE_PATH environment variable to the form (for client side requests) + String relativePath = ""; + String envRelativePath = System.getenv("KC_HTTP_RELATIVE_PATH"); + if (envRelativePath != null && !envRelativePath.isEmpty()) { + relativePath = envRelativePath; + } + form.setAttribute("KC_HTTP_RELATIVE_PATH", relativePath); + + return form; + } + + @Override + protected Response challenge(AuthenticationFlowContext context, MultivaluedMap formData) { + LoginFormsProvider forms = context.form(); + if (!formData.isEmpty()) { + forms.setFormData(formData); + } + + forms = assemblyForm(context, forms); + + return forms.createLoginUsernamePassword(); + } + + @Override + protected Response challenge(AuthenticationFlowContext context, String error, String field) { + LoginFormsProvider forms = context.form(); + forms = assemblyForm(context, forms); + + if (!Objects.isNull(error)) { + forms.addError(new FormMessage(field, error)); + } + + return forms.createLoginUsernamePassword(); + } + + @Override + protected boolean validateForm(AuthenticationFlowContext context, MultivaluedMap inputData) { + + boolean byPhone = OptionalUtils + .ofBlank(inputData.getFirst(FIELD_PATH_PHONE_ACTIVATED)) + .map(s -> "true".equalsIgnoreCase(s) || "yes".equalsIgnoreCase(s)) + .orElse(false); + + if (!byPhone) { + logger.debug("Setting attempted use phone as username: " + inputData.getFirst(FIELD_PHONE_NUMBER_AS_USERNAME)); + context.form().setAttribute(ATTEMPTED_PHONE_NUMBER_AS_USERNAME, inputData.getFirst(FIELD_PHONE_NUMBER_AS_USERNAME)); + + return validateUserAndPassword(context, inputData); + } + String phoneNumber = inputData.getFirst(FIELD_PHONE_NUMBER); + + if (Validation.isBlank(phoneNumber)) { + context.getEvent().error(Errors.USERNAME_MISSING); + context.form().setAttribute(ATTEMPTED_PHONE_ACTIVATED, true); + assemblyForm(context, context.form()); + Response challengeResponse = challenge(context, SupportPhonePages.Errors.MISSING.message(), FIELD_PHONE_NUMBER); + context.forceChallenge(challengeResponse); + return false; + } + + String code = inputData.getFirst(FIELD_VERIFICATION_CODE); + if (Validation.isBlank(code)) { + invalidVerificationCode(context, phoneNumber); + return false; + } + + return validatePhone(context, phoneNumber, code.trim()); + } + + private void invalidVerificationCode(AuthenticationFlowContext context, String phoneNumber) { + logger.warn("Invalid verification code for phone number: " + phoneNumber); + + context.getEvent().error(Errors.INVALID_USER_CREDENTIALS); + context.form() + .setAttribute(ATTEMPTED_PHONE_ACTIVATED, true) + .setAttribute(ATTEMPTED_PHONE_NUMBER, phoneNumber); + assemblyForm(context, context.form()); + Response challengeResponse = challenge(context, SupportPhonePages.Errors.NOT_MATCH.message(), FIELD_VERIFICATION_CODE); + context.failureChallenge(AuthenticationFlowError.INVALID_CREDENTIALS, challengeResponse); + } + + private boolean validatePhone(AuthenticationFlowContext context, String phoneNumber, String code) { + context.clearUser(); + + try { + var validPhoneNumber = Utils.canonicalizePhoneNumber(context.getSession(), phoneNumber); + + return Utils.findUserByPhone(context.getSession(), context.getRealm(), validPhoneNumber) + .map(user -> validateVerificationCode(context, user, validPhoneNumber, code) && validateUser(context, user, validPhoneNumber)) + .orElseGet(() -> { + context.getEvent().error(Errors.USER_NOT_FOUND); + context.form() + .setAttribute(ATTEMPTED_PHONE_ACTIVATED, true) + .setAttribute(ATTEMPTED_PHONE_NUMBER, phoneNumber); + assemblyForm(context, context.form()); + Response challengeResponse = challenge(context, SupportPhonePages.Errors.USER_NOT_FOUND.message(), FIELD_PHONE_NUMBER); + context.failureChallenge(AuthenticationFlowError.INVALID_USER, challengeResponse); + return false; + }); + } catch (PhoneNumberInvalidException e) { + context.getEvent().error(Errors.USERNAME_MISSING); + context.form() + .setAttribute(ATTEMPTED_PHONE_ACTIVATED, true) + .setAttribute(ATTEMPTED_PHONE_NUMBER, phoneNumber); + assemblyForm(context, context.form()); + Response challengeResponse = challenge(context, e.getErrorType().message(), FIELD_PHONE_NUMBER); + context.failureChallenge(AuthenticationFlowError.INVALID_USER, challengeResponse); + return false; + } + } + + private boolean validateVerificationCode(AuthenticationFlowContext context, UserModel user, String phoneNumber, String code) { + try { + context.getSession().getProvider(PhoneVerificationCodeProvider.class) + .validateCode(user, phoneNumber, code, TokenCodeType.AUTH); + return true; + } catch (Exception e) { + + context.getEvent().user(user); + invalidVerificationCode(context, phoneNumber); + return false; + } + } + + private boolean isDisabledByBruteForce(AuthenticationFlowContext context, UserModel user, String phoneNumber) { + String bruteForceError = getDisabledByBruteForceEventError(context, user); + if (bruteForceError != null) { + context.getEvent().user(user); + context.getEvent().error(bruteForceError); + context.form() + .setAttribute(ATTEMPTED_PHONE_ACTIVATED, true) + .setAttribute(ATTEMPTED_PHONE_NUMBER, phoneNumber); + assemblyForm(context, context.form()); + Response challengeResponse = challenge(context, disabledByBruteForceError(), disabledByBruteForceFieldError()); + context.forceChallenge(challengeResponse); + return true; + } + return false; + } + + private boolean enabledUser(AuthenticationFlowContext context, UserModel user, String phoneNumber) { + if (isDisabledByBruteForce(context, user, phoneNumber)) { + return false; + } + if (!user.isEnabled()) { + context.getEvent().user(user); + context.getEvent().error(Errors.USER_DISABLED); + context.form() + .setAttribute(ATTEMPTED_PHONE_ACTIVATED, true) + .setAttribute(ATTEMPTED_PHONE_NUMBER, phoneNumber); + assemblyForm(context, context.form()); + Response challengeResponse = challenge(context, Messages.ACCOUNT_DISABLED); + context.forceChallenge(challengeResponse); + return false; + } + return true; + } + + private boolean validateUser(AuthenticationFlowContext context, UserModel user, String phoneNumber) { + if (!enabledUser(context, user, phoneNumber)) { + return false; + } + context.getAuthenticationSession().setAuthNote(VERIFIED_PHONE_NUMBER, phoneNumber); + context.setUser(user); + return true; + } + + private boolean validateUser(AuthenticationFlowContext context, UserModel user, MultivaluedMap inputData) { + if (!enabledUser(context, user)) { + return false; + } + String rememberMe = inputData.getFirst("rememberMe"); + boolean remember = rememberMe != null && rememberMe.equalsIgnoreCase("on"); + if (remember) { + context.getAuthenticationSession().setAuthNote(Details.REMEMBER_ME, "true"); + context.getEvent().detail(Details.REMEMBER_ME, "true"); + } else { + context.getAuthenticationSession().removeAuthNote(Details.REMEMBER_ME); + } + context.setUser(user); + return true; + } + + @Override + public boolean validateUserAndPassword(AuthenticationFlowContext context, MultivaluedMap inputData) { + UserModel user = getUser(context, inputData); + boolean shouldClearUserFromCtxAfterBadPassword = !isUserAlreadySetBeforeUsernamePasswordAuth(context); + return user != null && validatePassword(context, user, inputData, shouldClearUserFromCtxAfterBadPassword) && validateUser(context, user, inputData); + } + + private UserModel getUser(AuthenticationFlowContext context, MultivaluedMap inputData) { + if (isUserAlreadySetBeforeUsernamePasswordAuth(context)) { + // Get user from the authentication context in case he was already set before this authenticator + UserModel user = context.getUser(); + testInvalidUser(context, user); + return user; + } else { + // Normal login. In this case this authenticator is supposed to establish identity of the user from the provided username + context.clearUser(); + return getUserFromForm(context, inputData); + } + } + + private UserModel getUserFromForm(AuthenticationFlowContext context, MultivaluedMap inputData) { + String username = inputData.getFirst(AuthenticationManager.FORM_USERNAME); + if (username == null) { + context.getEvent().error(Errors.USER_NOT_FOUND); + Response challengeResponse = challenge(context, getDefaultChallengeMessage(context), FIELD_USERNAME); + assemblyForm(context, context.form()); + context.failureChallenge(AuthenticationFlowError.INVALID_USER, challengeResponse); + return null; + } + + // remove leading and trailing whitespace + username = username.trim(); + + context.getEvent().detail(Details.USERNAME, username); + context.getAuthenticationSession().setAuthNote(AbstractUsernameFormAuthenticator.ATTEMPTED_USERNAME, username); + + UserModel user; + try { + user = KeycloakModelUtils.findUserByNameOrEmail(context.getSession(), context.getRealm(), username); + if (user == null + && isLoginWithPhoneNumber(context)) { + user = Utils.findUserByPhone(context.getSession(), context.getRealm(), username).orElse(null); + } + } catch (ModelDuplicateException mde) { + ServicesLogger.LOGGER.modelDuplicateException(mde); + + // Could happen during federation import + if (mde.getDuplicateFieldName() != null && mde.getDuplicateFieldName().equals(UserModel.EMAIL)) { + setDuplicateUserChallenge(context, Errors.EMAIL_IN_USE, Messages.EMAIL_EXISTS, AuthenticationFlowError.INVALID_USER); + } else { + setDuplicateUserChallenge(context, Errors.USERNAME_IN_USE, Messages.USERNAME_EXISTS, AuthenticationFlowError.INVALID_USER); + } + return null; + } + + testInvalidUser(context, user); + return user; + } + + @Override + public String getDisplayType() { + return "Phone Username Password Form"; + } + + @Override + public String getReferenceCategory() { + return PasswordCredentialModel.TYPE; + } + + public static final AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = { + AuthenticationExecutionModel.Requirement.REQUIRED + }; + + @Override + public AuthenticationExecutionModel.Requirement[] getRequirementChoices() { + return REQUIREMENT_CHOICES; + } + + @Override + public boolean isUserSetupAllowed() { + return false; + } + + @Override + public String getHelpText() { + return "Validates a username and password or phone and verification code from login form."; + } + + protected static final List CONFIG_PROPERTIES; + + static { + CONFIG_PROPERTIES = ProviderConfigurationBuilder.create() + .property().name(CONFIG_IS_LOGIN_WITH_PHONE_VERIFY) + .type(BOOLEAN_TYPE) + .label("Login with phone verify") + .helpText("Input phone number and password. `Duplicate phone` must be false.") + .defaultValue(true) + .add() + .property().name(CONFIG_IS_LOGIN_WITH_PHONE_NUMBER) + .type(BOOLEAN_TYPE) + .label("Login with phone number") + .helpText("Input phone number and password. `Duplicate phone` must be false.") + .defaultValue(true) + .add() + .build(); + } + + @Override + public boolean isConfigurable() { + return true; + } + + @Override + public List getConfigProperties() { + return CONFIG_PROPERTIES; + } + + @Override + public Authenticator create(KeycloakSession session) { + return this; + } + + @Override + public void init(Config.Scope config) { + + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + + } + + @Override + public String getId() { + return PROVIDER_ID; + } +} diff --git a/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/authentication/authenticators/browser/SmsOtpMfaAuthenticator.java b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/authentication/authenticators/browser/SmsOtpMfaAuthenticator.java new file mode 100644 index 000000000..d31776b7f --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/authentication/authenticators/browser/SmsOtpMfaAuthenticator.java @@ -0,0 +1,217 @@ +package cc.coopersoft.keycloak.phone.authentication.authenticators.browser; + +import java.net.URI; +import java.util.Optional; + +import org.jboss.logging.Logger; +import org.jboss.resteasy.spi.HttpResponse; +import org.keycloak.authentication.AuthenticationFlowContext; +import org.keycloak.authentication.AuthenticationFlowError; +import org.keycloak.authentication.Authenticator; +import org.keycloak.authentication.CredentialValidator; +import org.keycloak.common.util.ServerCookie; +import org.keycloak.credential.CredentialProvider; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserCredentialModel; +import org.keycloak.models.UserModel; +import org.keycloak.services.validation.Validation; + +import cc.coopersoft.common.OptionalUtils; +import cc.coopersoft.keycloak.phone.Utils; +import static cc.coopersoft.keycloak.phone.authentication.authenticators.browser.PhoneUsernamePasswordForm.VERIFIED_PHONE_NUMBER; +import cc.coopersoft.keycloak.phone.authentication.forms.SupportPhonePages; +import static cc.coopersoft.keycloak.phone.authentication.forms.SupportPhonePages.ATTRIBUTE_SUPPORT_PHONE; +import cc.coopersoft.keycloak.phone.authentication.requiredactions.ConfigSmsOtpRequiredAction; +import cc.coopersoft.keycloak.phone.credential.PhoneOtpCredentialModel; +import cc.coopersoft.keycloak.phone.credential.PhoneOtpCredentialProvider; +import cc.coopersoft.keycloak.phone.credential.PhoneOtpCredentialProviderFactory; +import cc.coopersoft.keycloak.phone.providers.constants.TokenCodeType; +import cc.coopersoft.keycloak.phone.providers.spi.PhoneProvider; +import jakarta.ws.rs.ForbiddenException; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MultivaluedMap; +import jakarta.ws.rs.core.Response; + +public class SmsOtpMfaAuthenticator implements Authenticator, CredentialValidator { + + private static final Logger logger = Logger.getLogger(SmsOtpMfaAuthenticator.class); + + private static final String PAGE = "login-sms-otp.ftl"; + + protected boolean validateCookie(AuthenticationFlowContext context) { + if (Utils.getOtpExpires(context.getSession()) <= 0) { + return false; + } + + var invalid = PhoneOtpCredentialModel.getSmsOtpCredentialData(context.getUser()) + .map(PhoneOtpCredentialModel.SmsOtpCredentialData::isSecretInvalid) + .orElse(true); + + if (invalid) { + return false; + } + + return Optional.of(context.getHttpRequest().getHttpHeaders().getCookies()) + .flatMap(cookies + -> Optional.ofNullable(cookies.get("SMS_OTP_ANSWERED")) + .flatMap(cookie -> OptionalUtils.ofBlank(cookie.getValue())) + .flatMap(credentialId + -> Optional.ofNullable(cookies.get(credentialId)) + .flatMap(cookie -> OptionalUtils.ofBlank(cookie.getValue())) + .map(secret -> context.getUser() + .credentialManager() + .isValid(new UserCredentialModel(credentialId, getType(context.getSession()), secret))) + ) + ).orElse(false); + } + + protected void setCookie(AuthenticationFlowContext context, String credentialId, String secret) { + + int maxCookieAge = Utils.getOtpExpires(context.getSession()); + + if (maxCookieAge <= 0) { + return; + } + + URI uri = context.getUriInfo() + .getBaseUriBuilder() + .path("realms") + .path(context.getRealm().getName()) + .build(); + + addCookie(context, "SMS_OTP_ANSWERED", credentialId, + uri.getRawPath(), + null, null, + maxCookieAge, + false, true); + addCookie(context, credentialId, secret, + uri.getRawPath(), + null, null, + maxCookieAge, + false, true); + } + + public void addCookie(AuthenticationFlowContext context, String name, String value, String path, String domain, String comment, int maxAge, boolean secure, boolean httpOnly) { + HttpResponse response = context.getSession().getContext().getContextObject(HttpResponse.class); + StringBuilder cookieBuf = new StringBuilder(); + ServerCookie.appendCookieValue(cookieBuf, 1, name, value, path, domain, comment, maxAge, secure, httpOnly, null); + String cookie = cookieBuf.toString(); + response.getOutputHeaders().add(HttpHeaders.SET_COOKIE, cookie); + } + + @Override + public PhoneOtpCredentialProvider getCredentialProvider(KeycloakSession session) { + return (PhoneOtpCredentialProvider) session.getProvider(CredentialProvider.class, PhoneOtpCredentialProviderFactory.PROVIDER_ID); + } + + private String getCredentialPhoneNumber(UserModel user) { + return PhoneOtpCredentialModel.getSmsOtpCredentialData(user) + .map(PhoneOtpCredentialModel.SmsOtpCredentialData::getPhoneNumber) + .orElseThrow(() -> new IllegalStateException("Not have OTP Credential")); + } + + @Override + public void authenticate(AuthenticationFlowContext context) { + + if (validateCookie(context)) { + context.success(); + return; + } + + String phoneNumber = getCredentialPhoneNumber(context.getUser()); + + boolean verified = OptionalUtils.ofBlank(context.getAuthenticationSession().getAuthNote(VERIFIED_PHONE_NUMBER)) + .map(number -> number.equalsIgnoreCase(phoneNumber)) + .orElse(false); + if (verified) { + context.success(); + return; + } + + PhoneProvider phoneProvider = context.getSession().getProvider(PhoneProvider.class); + try { + int expires = phoneProvider.sendTokenCode(phoneNumber, context.getConnection().getRemoteAddr(), + TokenCodeType.OTP, null); + context.form() + .setInfo("codeSent", phoneNumber) + .setAttribute("expires", expires) + .setAttribute("initSend", true); + } catch (ForbiddenException e) { + logger.warn("otp send code Forbidden Exception!", e); + context.form().setError(SupportPhonePages.Errors.ABUSED.message()); + } catch (Exception e) { + logger.warn("otp send code Exception!", e); + context.form().setError(SupportPhonePages.Errors.FAIL.message()); + } + + var credentialData = new PhoneOtpCredentialModel.SmsOtpCredentialData(phoneNumber, 0); + PhoneOtpCredentialModel.updateOtpCredential(context.getUser(), credentialData, null); + + Response challenge = challenge(context, phoneNumber); + context.challenge(challenge); + } + + @Override + public void action(AuthenticationFlowContext context) { + MultivaluedMap formData = context.getHttpRequest().getDecodedFormParameters(); + String secret = formData.getFirst("code"); + String credentialId = formData.getFirst("credentialId"); + + String phoneNumber = getCredentialPhoneNumber(context.getUser()); + + if (credentialId == null || credentialId.isEmpty()) { + var defaultOtpCredential = getCredentialProvider(context.getSession()) + .getDefaultCredential(context.getSession(), context.getRealm(), context.getUser()); + credentialId = defaultOtpCredential == null ? "" : defaultOtpCredential.getId(); + } + + if (Validation.isBlank(secret)) { + context.form() + .setError(SupportPhonePages.Errors.NOT_MATCH.message()); + Response challenge = challenge(context, phoneNumber); + context.failureChallenge(AuthenticationFlowError.INVALID_CREDENTIALS, challenge); + } + + UserCredentialModel input = new UserCredentialModel(credentialId, getType(context.getSession()), secret); + + boolean validated = getCredentialProvider(context.getSession()).isValid(context.getRealm(), context.getUser(), input); + + if (!validated) { + context.form() + .setError(SupportPhonePages.Errors.NOT_MATCH.message()); + Response challenge = challenge(context, phoneNumber); + context.failureChallenge(AuthenticationFlowError.INVALID_CREDENTIALS, challenge); + return; + } + setCookie(context, credentialId, secret); + context.success(); + } + + protected Response challenge(AuthenticationFlowContext context, String phoneNumber) { + return context.form() + .setAttribute(ATTRIBUTE_SUPPORT_PHONE, true) + .setAttribute(SupportPhonePages.ATTEMPTED_PHONE_NUMBER, phoneNumber) + .createForm(PAGE); + } + + @Override + public boolean requiresUser() { + return true; + } + + @Override + public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) { + return getCredentialProvider(session).isConfiguredFor(realm, user, getType(session)); + } + + @Override + public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) { + user.addRequiredAction(ConfigSmsOtpRequiredAction.PROVIDER_ID); + } + + @Override + public void close() { + + } +} diff --git a/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/authentication/authenticators/browser/SmsOtpMfaAuthenticatorFactory.java b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/authentication/authenticators/browser/SmsOtpMfaAuthenticatorFactory.java new file mode 100644 index 000000000..a95aa2e6b --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/authentication/authenticators/browser/SmsOtpMfaAuthenticatorFactory.java @@ -0,0 +1,86 @@ +package cc.coopersoft.keycloak.phone.authentication.authenticators.browser; + +import cc.coopersoft.keycloak.phone.authentication.authenticators.resetcred.ResetCredentialWithPhone; +import org.keycloak.Config.Scope; +import org.keycloak.authentication.Authenticator; +import org.keycloak.authentication.AuthenticatorFactory; +import org.keycloak.authentication.ConfigurableAuthenticatorFactory; +import org.keycloak.models.AuthenticationExecutionModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.provider.ProviderConfigProperty; + +import java.util.Collections; +import java.util.List; + +import static org.keycloak.provider.ProviderConfigProperty.STRING_TYPE; + +public class SmsOtpMfaAuthenticatorFactory implements AuthenticatorFactory, ConfigurableAuthenticatorFactory { + + public final static String PROVIDER_ID = "sms-otp-authenticator"; + +// public final static String COOKIE_MAX_AGE= "cookieMaxAge"; + private final static SmsOtpMfaAuthenticator instance = new SmsOtpMfaAuthenticator(); + + @Override + public String getDisplayType() { + return "OTP over SMS"; + } + + @Override + public String getReferenceCategory() { + return "OTP over SMS"; + } + + @Override + public boolean isConfigurable() { + return true; + } + + @Override + public AuthenticationExecutionModel.Requirement[] getRequirementChoices() { + return REQUIREMENT_CHOICES; + } + + @Override + public boolean isUserSetupAllowed() { + return true; + } + + @Override + public String getHelpText() { + return "Asks for a token sent to users' mobile phone"; + } + + @Override + public List getConfigProperties() { +// ProviderConfigProperty rep = +// new ProviderConfigProperty(COOKIE_MAX_AGE, +// "Cookie Max Age", +// "Max age in seconds of the SMS_OTP_COOKIE. Zero is Don't use Cookie", +// STRING_TYPE, "3600"); + return null; + } + + @Override + public Authenticator create(KeycloakSession keycloakSession) { + return instance; + } + + @Override + public void init(Scope scope) { + } + + @Override + public void postInit(KeycloakSessionFactory keycloakSessionFactory) { + } + + @Override + public void close() { + } + + @Override + public String getId() { + return PROVIDER_ID; + } +} diff --git a/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/authentication/authenticators/conditional/ConditionalPhoneProvided.java b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/authentication/authenticators/conditional/ConditionalPhoneProvided.java new file mode 100644 index 000000000..a1846749f --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/authentication/authenticators/conditional/ConditionalPhoneProvided.java @@ -0,0 +1,45 @@ +package cc.coopersoft.keycloak.phone.authentication.authenticators.conditional; + +import cc.coopersoft.common.OptionalUtils; +import org.keycloak.authentication.AuthenticationFlowContext; +import org.keycloak.authentication.authenticators.conditional.ConditionalAuthenticator; +import org.keycloak.authentication.authenticators.conditional.ConditionalUserAttributeValueFactory; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; + +public class ConditionalPhoneProvided implements ConditionalAuthenticator { + + static final ConditionalPhoneProvided SINGLETON = new ConditionalPhoneProvided(); + + @Override + public boolean matchCondition(AuthenticationFlowContext context) { + var config = context.getAuthenticatorConfig().getConfig(); + boolean negateOutput = Boolean.parseBoolean(config.getOrDefault(ConditionalUserAttributeValueFactory.CONF_NOT,"false")); + + boolean result = OptionalUtils.ofBlank(OptionalUtils.ofBlank( + context.getHttpRequest().getDecodedFormParameters().getFirst("phone_number")) + .orElse(context.getHttpRequest().getDecodedFormParameters().getFirst("phoneNumber"))).isPresent(); + return negateOutput != result; + } + + @Override + public void action(AuthenticationFlowContext authenticationFlowContext) { + // Not used + } + + @Override + public boolean requiresUser() { + return false; + } + + @Override + public void setRequiredActions(KeycloakSession keycloakSession, RealmModel realmModel, UserModel userModel) { + // Not used + } + + @Override + public void close() { + // Does nothing + } +} diff --git a/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/authentication/authenticators/conditional/ConditionalPhoneProvidedFactory.java b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/authentication/authenticators/conditional/ConditionalPhoneProvidedFactory.java new file mode 100644 index 000000000..15281fccb --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/authentication/authenticators/conditional/ConditionalPhoneProvidedFactory.java @@ -0,0 +1,82 @@ +package cc.coopersoft.keycloak.phone.authentication.authenticators.conditional; + +import org.keycloak.Config; +import org.keycloak.authentication.authenticators.conditional.ConditionalAuthenticator; +import org.keycloak.authentication.authenticators.conditional.ConditionalAuthenticatorFactory; +import org.keycloak.models.AuthenticationExecutionModel; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.provider.ProviderConfigProperty; + +import java.util.Collections; +import java.util.List; + +public class ConditionalPhoneProvidedFactory implements ConditionalAuthenticatorFactory { + + public static final String PROVIDER_ID = "conditional-phone-provided"; + public static final String CONF_NOT = "not"; + + private static final AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = { + AuthenticationExecutionModel.Requirement.REQUIRED, AuthenticationExecutionModel.Requirement.DISABLED + }; + + @Override + public ConditionalAuthenticator getSingleton() { + return ConditionalPhoneProvided.SINGLETON; + } + + @Override + public String getDisplayType() { + return "Condition - phone provided"; + } + + @Override + public boolean isConfigurable() { + return true; + } + + @Override + public AuthenticationExecutionModel.Requirement[] getRequirementChoices() { + return REQUIREMENT_CHOICES; + } + + @Override + public boolean isUserSetupAllowed() { + return false; + } + + @Override + public String getHelpText() { + return "Flow is executed only if the phone number provided"; + } + + @Override + public List getConfigProperties() { + ProviderConfigProperty negateOutput = new ProviderConfigProperty(); + negateOutput.setType(ProviderConfigProperty.BOOLEAN_TYPE); + negateOutput.setName(CONF_NOT); + negateOutput.setLabel("Negate output"); + negateOutput.setHelpText("Apply a not to the check result"); + + return Collections.singletonList(negateOutput); + } + + @Override + public void init(Config.Scope scope) { + + } + + @Override + public void postInit(KeycloakSessionFactory keycloakSessionFactory) { + + } + + @Override + public void close() { + + } + + @Override + public String getId() { + return PROVIDER_ID; + } +} diff --git a/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/authentication/authenticators/directgrant/AuthenticationCodeAuthenticator.java b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/authentication/authenticators/directgrant/AuthenticationCodeAuthenticator.java new file mode 100644 index 000000000..6c00086d2 --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/authentication/authenticators/directgrant/AuthenticationCodeAuthenticator.java @@ -0,0 +1,61 @@ +package cc.coopersoft.keycloak.phone.authentication.authenticators.directgrant; + +import cc.coopersoft.keycloak.phone.providers.constants.TokenCodeType; +import cc.coopersoft.keycloak.phone.providers.spi.PhoneVerificationCodeProvider; +import org.jboss.logging.Logger; +import org.keycloak.authentication.AuthenticationFlowContext; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; + + +public class AuthenticationCodeAuthenticator extends BaseDirectGrantAuthenticator { + + private static final Logger logger = Logger.getLogger(AuthenticationCodeAuthenticator.class); + + public AuthenticationCodeAuthenticator(KeycloakSession session) { + if (session.getContext().getRealm() == null) { + throw new IllegalStateException("The service cannot accept a session without a realm in its context."); + } + } + + @Override + public boolean requiresUser() { + return true; + } + + @Override + public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) { + } + + @Override + public void authenticate(AuthenticationFlowContext context) { + getPhoneNumber(context).ifPresentOrElse(number -> validateVerificationCode(context,number), + ()-> invalidCredentials(context,context.getUser())); + } + + + + private void validateVerificationCode(AuthenticationFlowContext context, String phoneNumber) { + + getAuthenticationCode(context).ifPresentOrElse(code -> { + try { + context.getSession().getProvider(PhoneVerificationCodeProvider.class).validateCode(context.getUser(), phoneNumber, code, TokenCodeType.AUTH); + context.success(); + } catch (Exception e) { + logger.info("Grant authenticator valid code failure",e); + invalidCredentials(context,context.getUser()); + } + },() -> invalidCredentials(context,context.getUser())); + +// String kind = null; +// AuthenticatorConfigModel authenticatorConfig = context.getAuthenticatorConfig(); +// if (authenticatorConfig != null && authenticatorConfig.getConfig() != null) { +// kind = Optional.ofNullable(context.getAuthenticatorConfig().getConfig().get(AuthenticationCodeAuthenticatorFactory.KIND)).orElse(""); +// } + + + + } + +} diff --git a/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/authentication/authenticators/directgrant/AuthenticationCodeAuthenticatorFactory.java b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/authentication/authenticators/directgrant/AuthenticationCodeAuthenticatorFactory.java new file mode 100644 index 000000000..f0ad9bea9 --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/authentication/authenticators/directgrant/AuthenticationCodeAuthenticatorFactory.java @@ -0,0 +1,102 @@ +package cc.coopersoft.keycloak.phone.authentication.authenticators.directgrant; + +import org.keycloak.Config; +import org.keycloak.authentication.Authenticator; +import org.keycloak.authentication.AuthenticatorFactory; +import org.keycloak.authentication.ConfigurableAuthenticatorFactory; +import org.keycloak.models.AuthenticationExecutionModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.provider.ProviderConfigProperty; + +import java.util.ArrayList; +import java.util.List; + +public class AuthenticationCodeAuthenticatorFactory implements AuthenticatorFactory, ConfigurableAuthenticatorFactory { + + public static final String PROVIDER_ID = "verification-code-authenticator"; + + @Override + public void init(Config.Scope config) { + + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + + } + + @Override + public void close() { + + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public Authenticator create(KeycloakSession session) { + return new AuthenticationCodeAuthenticator(session); + } + + private static AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = { + AuthenticationExecutionModel.Requirement.REQUIRED, + //AuthenticationExecutionModel.Requirement.DISABLED + }; + @Override + public AuthenticationExecutionModel.Requirement[] getRequirementChoices() { + return REQUIREMENT_CHOICES; + } + + @Override + public boolean isUserSetupAllowed() { + return true; + } + + @Override + public List getConfigProperties() { + return null; + } + +// private static final List configProperties = new ArrayList<>(); + +// static { +// ProviderConfigProperty maxAge; +// maxAge = new ProviderConfigProperty(); +// maxAge.setName(MAX_AGE); +// maxAge.setLabel("Verification Code Max Age"); +// maxAge.setType(ProviderConfigProperty.STRING_TYPE); +// maxAge.setHelpText("Max age in seconds of the verification codes."); +// configProperties.add(maxAge); +// ProviderConfigProperty kind = new ProviderConfigProperty(); +// kind.setName(KIND); +// kind.setLabel("Verification Code Kind"); +// kind.setType(ProviderConfigProperty.STRING_TYPE); +// kind.setHelpText("a string that identifies what the verification code is used for, if this is set, " + +// "a parameter of 'kind' is required to be equal with set value"); +// configProperties.add(kind); +// } + + @Override + public String getDisplayType() { + return "Provide verification code"; + } + + @Override + public String getHelpText() { + return "Provide verification code"; + } + + @Override + public String getReferenceCategory() { + return "Verification Code Grant"; + } + + @Override + public boolean isConfigurable() { + return false; + } +} + diff --git a/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/authentication/authenticators/directgrant/BaseDirectGrantAuthenticator.java b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/authentication/authenticators/directgrant/BaseDirectGrantAuthenticator.java new file mode 100644 index 000000000..8554ddc30 --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/authentication/authenticators/directgrant/BaseDirectGrantAuthenticator.java @@ -0,0 +1,61 @@ +package cc.coopersoft.keycloak.phone.authentication.authenticators.directgrant; + +import cc.coopersoft.common.OptionalUtils; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import org.keycloak.authentication.AuthenticationFlowContext; +import org.keycloak.authentication.AuthenticationFlowError; +import org.keycloak.authentication.Authenticator; +import org.keycloak.events.Errors; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.representations.idm.OAuth2ErrorRepresentation; + +import java.util.Optional; + +public abstract class BaseDirectGrantAuthenticator implements Authenticator { + + public Response errorResponse(int status, String error, String errorDescription) { + OAuth2ErrorRepresentation errorRep = new OAuth2ErrorRepresentation(error, errorDescription); + return Response.status(status).entity(errorRep).type(MediaType.APPLICATION_JSON_TYPE).build(); + } + + protected Optional getPhoneNumber(AuthenticationFlowContext context){ + return OptionalUtils.ofBlank(OptionalUtils.ofBlank( + context.getHttpRequest().getDecodedFormParameters().getFirst("phone_number")) + .orElse(context.getHttpRequest().getDecodedFormParameters().getFirst("phoneNumber"))); + } + + protected Optional getAuthenticationCode(AuthenticationFlowContext context){ + return OptionalUtils.ofBlank(context.getHttpRequest().getDecodedFormParameters().getFirst("code")); + } + + protected void invalidCredentials(AuthenticationFlowContext context,AuthenticationFlowError error){ + context.getEvent().error(Errors.INVALID_USER_CREDENTIALS); + Response challenge = errorResponse(Response.Status.UNAUTHORIZED.getStatusCode(), "invalid_grant", "Invalid user credentials"); + context.failure(error, challenge); + } + + protected void invalidCredentials(AuthenticationFlowContext context, UserModel user){ + context.getEvent().user(user); + invalidCredentials(context,AuthenticationFlowError.INVALID_CREDENTIALS); + } + + protected void invalidCredentials(AuthenticationFlowContext context){ + invalidCredentials(context,AuthenticationFlowError.INVALID_USER); + } + + @Override + public void close() {} + + @Override + public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) { + return true; + } + + @Override + public void action(AuthenticationFlowContext context) { + authenticate(context); + } +} diff --git a/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/authentication/authenticators/directgrant/EverybodyPhoneAuthenticator.java b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/authentication/authenticators/directgrant/EverybodyPhoneAuthenticator.java new file mode 100644 index 000000000..29a0c6f09 --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/authentication/authenticators/directgrant/EverybodyPhoneAuthenticator.java @@ -0,0 +1,72 @@ +package cc.coopersoft.keycloak.phone.authentication.authenticators.directgrant; + +import cc.coopersoft.keycloak.phone.providers.constants.TokenCodeType; +import cc.coopersoft.keycloak.phone.providers.representations.TokenCodeRepresentation; +import cc.coopersoft.keycloak.phone.providers.spi.PhoneVerificationCodeProvider; +import cc.coopersoft.keycloak.phone.Utils; +import org.jboss.logging.Logger; +import org.keycloak.authentication.AuthenticationFlowContext; +import org.keycloak.authentication.AuthenticationFlowError; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.protocol.oidc.OIDCLoginProtocol; + + +public class EverybodyPhoneAuthenticator extends BaseDirectGrantAuthenticator { + + private static final Logger logger = Logger.getLogger(EverybodyPhoneAuthenticator.class); + + public EverybodyPhoneAuthenticator(KeycloakSession session) { + if (session.getContext().getRealm() == null) { + throw new IllegalStateException("The service cannot accept a session without a realm in its context."); + } + } + + @Override + public boolean requiresUser() { + return false; + } + + @Override + public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) { + + } + + @Override + public void authenticate(AuthenticationFlowContext context) { + getPhoneNumber(context) + .ifPresentOrElse(phoneNumber -> getAuthenticationCode(context) + .ifPresentOrElse(code -> authToUser(context, phoneNumber, code), + ()-> invalidCredentials(context)), + () -> invalidCredentials(context)); + } + + private void authToUser(AuthenticationFlowContext context, String phoneNumber, String code) { + PhoneVerificationCodeProvider phoneVerificationCodeProvider = context.getSession().getProvider(PhoneVerificationCodeProvider.class); + TokenCodeRepresentation tokenCode = phoneVerificationCodeProvider.ongoingProcess(phoneNumber, TokenCodeType.AUTH); + + if (tokenCode == null || !tokenCode.getCode().equals(code)) { + invalidCredentials(context); + return; + } + + UserModel user = Utils.findUserByPhone(context.getSession(), context.getRealm(), phoneNumber) + .orElseGet(() -> { + if (context.getSession().users().getUserByUsername(context.getRealm(),phoneNumber) != null) { + invalidCredentials(context, AuthenticationFlowError.USER_CONFLICT); + return null; + } + UserModel newUser = context.getSession().users().addUser(context.getRealm(), phoneNumber); + + newUser.setEnabled(true); + context.getAuthenticationSession().setClientNote(OIDCLoginProtocol.LOGIN_HINT_PARAM, phoneNumber); + return newUser; + }); + if (user != null) { + context.setUser(user); + phoneVerificationCodeProvider.tokenValidated(user, phoneNumber, tokenCode.getId(),false); + context.success(); + } + } +} diff --git a/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/authentication/authenticators/directgrant/EverybodyPhoneAuthenticatorFactory.java b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/authentication/authenticators/directgrant/EverybodyPhoneAuthenticatorFactory.java new file mode 100644 index 000000000..953142bf7 --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/authentication/authenticators/directgrant/EverybodyPhoneAuthenticatorFactory.java @@ -0,0 +1,84 @@ +package cc.coopersoft.keycloak.phone.authentication.authenticators.directgrant; + +import org.keycloak.Config; +import org.keycloak.authentication.Authenticator; +import org.keycloak.authentication.AuthenticatorFactory; +import org.keycloak.authentication.ConfigurableAuthenticatorFactory; +import org.keycloak.models.AuthenticationExecutionModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.provider.ProviderConfigProperty; + +import java.util.List; + +public class EverybodyPhoneAuthenticatorFactory implements AuthenticatorFactory, ConfigurableAuthenticatorFactory { + + + public static final String PROVIDER_ID = "everybody-phone-authenticator"; + + @Override + public Authenticator create(KeycloakSession session){ + return new EverybodyPhoneAuthenticator(session); + } + + @Override + public void init(Config.Scope config) { + + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + + } + + @Override + public void close() { + + } + + + @Override + public String getDisplayType() { + return "Authentication everybody by phone"; + } + + @Override + public String getHelpText() { + return "Authentication everybody by phone"; + } + + @Override + public List getConfigProperties() { + return null; + } + + @Override + public String getReferenceCategory() { + return "Everybody phone Grant"; + } + + @Override + public boolean isConfigurable() { + return false; + } + + private static AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = { + AuthenticationExecutionModel.Requirement.REQUIRED, + AuthenticationExecutionModel.Requirement.DISABLED + }; + + @Override + public AuthenticationExecutionModel.Requirement[] getRequirementChoices() { + return REQUIREMENT_CHOICES; + } + + @Override + public boolean isUserSetupAllowed() { + return true; + } + + @Override + public String getId() { + return PROVIDER_ID; + } +} diff --git a/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/authentication/authenticators/directgrant/PhoneNumberAuthenticator.java b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/authentication/authenticators/directgrant/PhoneNumberAuthenticator.java new file mode 100644 index 000000000..8b1034539 --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/authentication/authenticators/directgrant/PhoneNumberAuthenticator.java @@ -0,0 +1,33 @@ +package cc.coopersoft.keycloak.phone.authentication.authenticators.directgrant; + +import cc.coopersoft.keycloak.phone.Utils; +import org.jboss.logging.Logger; +import org.keycloak.authentication.AuthenticationFlowContext; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; + +public class PhoneNumberAuthenticator extends BaseDirectGrantAuthenticator { + + private static final Logger logger = Logger.getLogger(PhoneNumberAuthenticator.class); + + @Override + public boolean requiresUser() { + return false; + } + + @Override + public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) { + } + + @Override + public void authenticate(AuthenticationFlowContext context) { + context.clearUser(); + getPhoneNumber(context).ifPresentOrElse(phoneNumber -> + Utils.findUserByPhone(context.getSession(),context.getRealm(),phoneNumber) + .ifPresentOrElse(user -> { + context.setUser(user); + context.success(); + },()->invalidCredentials(context)),() -> invalidCredentials(context)); + } +} diff --git a/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/authentication/authenticators/directgrant/PhoneNumberAuthenticatorFactory.java b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/authentication/authenticators/directgrant/PhoneNumberAuthenticatorFactory.java new file mode 100644 index 000000000..3e28517dc --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/authentication/authenticators/directgrant/PhoneNumberAuthenticatorFactory.java @@ -0,0 +1,84 @@ +package cc.coopersoft.keycloak.phone.authentication.authenticators.directgrant; + +import org.keycloak.Config; +import org.keycloak.authentication.Authenticator; +import org.keycloak.authentication.AuthenticatorFactory; +import org.keycloak.authentication.ConfigurableAuthenticatorFactory; +import org.keycloak.models.AuthenticationExecutionModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.provider.ProviderConfigProperty; + +import java.util.ArrayList; +import java.util.List; + +public class PhoneNumberAuthenticatorFactory implements AuthenticatorFactory, ConfigurableAuthenticatorFactory { + + public static final String PROVIDER_ID = "phone-number-authenticator"; + private static final PhoneNumberAuthenticator SINGLETON = new PhoneNumberAuthenticator(); + + @Override + public void init(Config.Scope config) { + + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + + } + + @Override + public void close() { + + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public Authenticator create(KeycloakSession session) { + return SINGLETON; + } + + private static AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = { + AuthenticationExecutionModel.Requirement.REQUIRED, + }; + + @Override + public AuthenticationExecutionModel.Requirement[] getRequirementChoices() { + return REQUIREMENT_CHOICES; + } + + @Override + public boolean isUserSetupAllowed() { + return true; + } + + @Override + public List getConfigProperties() { + return null; + } + + @Override + public String getDisplayType() { + return "Provide phone number"; + } + + @Override + public String getHelpText() { + return "Provide phone number"; + } + + @Override + public String getReferenceCategory() { + return "Phone Number Grant"; + } + + @Override + public boolean isConfigurable() { + return false; + } +} + diff --git a/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/authentication/authenticators/resetcred/ResetCredentialEmailWithPhone.java b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/authentication/authenticators/resetcred/ResetCredentialEmailWithPhone.java new file mode 100644 index 000000000..cece28b09 --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/authentication/authenticators/resetcred/ResetCredentialEmailWithPhone.java @@ -0,0 +1,74 @@ +package cc.coopersoft.keycloak.phone.authentication.authenticators.resetcred; + +import org.jboss.logging.Logger; +import org.keycloak.authentication.AuthenticationFlowContext; +import org.keycloak.authentication.authenticators.resetcred.ResetCredentialEmail; +import org.keycloak.models.AuthenticationExecutionModel.Requirement; + +public class ResetCredentialEmailWithPhone extends ResetCredentialEmail { + + public static final Requirement[] REQUIREMENT_CHOICES; + + private static final Logger logger = Logger.getLogger(ResetCredentialEmail.class); + + //TODO Requirement.CONDITIONAL or ALTERNATIVE ? configuredFor + /** + * REQUIRED ignore configuredFor always execute ALTERNATIVE same level multi + * item check configuredFor choice one CONDITIONAL check condition item And + * check configuredFor DISABLED ignore all + */ + static { + REQUIREMENT_CHOICES = new Requirement[]{ + Requirement.CONDITIONAL, + Requirement.ALTERNATIVE, + Requirement.DISABLED + }; + } + + @Override + public void authenticate(AuthenticationFlowContext context) { + // Write out KC_HTTP_RELATIVE_PATH environment variable to the form (for client side requests) + String relativePath = ""; + String envRelativePath = System.getenv("KC_HTTP_RELATIVE_PATH"); + if (envRelativePath != null && !envRelativePath.isEmpty()) { + relativePath = envRelativePath; + } + var form = context.form(); + form.setAttribute("KC_HTTP_RELATIVE_PATH", relativePath); + + if (context.getExecution().isRequired() + || (context.getExecution().isConditional() + && configuredFor(context))) { + super.authenticate(context); + } else { + context.success(); + } + } + + protected boolean configuredFor(AuthenticationFlowContext context) { + String sendNote = context.getAuthenticationSession().getAuthNote(ResetCredentialWithPhone.SHOULD_SEND_EMAIL); + logger.info("call if no phone email configuredFor:" + sendNote); + return !"false".equalsIgnoreCase(sendNote); + } + + @Override + public String getId() { + return "reset-credential-email-with-phone"; + } + + @Override + public String getDisplayType() { + return "Send Reset Email If Not Phone"; + } + + @Override + public String getHelpText() { + return "Send email to user if not phone provided."; + } + + @Override + public Requirement[] getRequirementChoices() { + return REQUIREMENT_CHOICES; + } + +} diff --git a/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/authentication/authenticators/resetcred/ResetCredentialWithPhone.java b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/authentication/authenticators/resetcred/ResetCredentialWithPhone.java new file mode 100644 index 000000000..f21d204b9 --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/authentication/authenticators/resetcred/ResetCredentialWithPhone.java @@ -0,0 +1,357 @@ +package cc.coopersoft.keycloak.phone.authentication.authenticators.resetcred; + +import java.util.List; + +import org.jboss.logging.Logger; +import org.keycloak.Config; +import org.keycloak.authentication.AuthenticationFlowContext; +import org.keycloak.authentication.AuthenticationFlowError; +import org.keycloak.authentication.Authenticator; +import org.keycloak.authentication.AuthenticatorFactory; +import org.keycloak.authentication.authenticators.broker.AbstractIdpAuthenticator; +import org.keycloak.authentication.authenticators.browser.AbstractUsernameFormAuthenticator; +import static org.keycloak.authentication.authenticators.util.AuthenticatorUtils.getDisabledByBruteForceEventError; +import org.keycloak.events.Details; +import org.keycloak.events.Errors; +import org.keycloak.models.AuthenticationExecutionModel; +import org.keycloak.models.DefaultActionTokenKey; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.models.utils.FormMessage; +import org.keycloak.provider.ProviderConfigProperty; +import org.keycloak.services.managers.AuthenticationManager; +import org.keycloak.services.messages.Messages; +import org.keycloak.services.validation.Validation; + +import cc.coopersoft.common.OptionalUtils; +import cc.coopersoft.keycloak.phone.Utils; +import cc.coopersoft.keycloak.phone.authentication.forms.SupportPhonePages; +import static cc.coopersoft.keycloak.phone.authentication.forms.SupportPhonePages.ATTEMPTED_PHONE_ACTIVATED; +import static cc.coopersoft.keycloak.phone.authentication.forms.SupportPhonePages.ATTEMPTED_PHONE_NUMBER; +import static cc.coopersoft.keycloak.phone.authentication.forms.SupportPhonePages.ATTRIBUTE_SUPPORT_PHONE; +import static cc.coopersoft.keycloak.phone.authentication.forms.SupportPhonePages.FIELD_PATH_PHONE_ACTIVATED; +import static cc.coopersoft.keycloak.phone.authentication.forms.SupportPhonePages.FIELD_PHONE_NUMBER; +import static cc.coopersoft.keycloak.phone.authentication.forms.SupportPhonePages.FIELD_VERIFICATION_CODE; +import cc.coopersoft.keycloak.phone.providers.constants.TokenCodeType; +import cc.coopersoft.keycloak.phone.providers.exception.PhoneNumberInvalidException; +import cc.coopersoft.keycloak.phone.providers.spi.PhoneVerificationCodeProvider; +import jakarta.ws.rs.core.MultivaluedMap; +import jakarta.ws.rs.core.Response; + +public class ResetCredentialWithPhone implements Authenticator, AuthenticatorFactory { + + private static final Logger logger = Logger.getLogger(ResetCredentialWithPhone.class); + + public static final String PROVIDER_ID = "reset-credentials-with-phone"; + + public static final String SHOULD_SEND_EMAIL = "should-send-email"; + + @Override + public void authenticate(AuthenticationFlowContext context) { + + String existingUserId = context.getAuthenticationSession().getAuthNote(AbstractIdpAuthenticator.EXISTING_USER_INFO); + if (existingUserId != null) { + UserModel existingUser = AbstractIdpAuthenticator.getExistingUser(context.getSession(), context.getRealm(), context.getAuthenticationSession()); + + logger.debugf("Forget-password triggered when reauthenticating user after first broker login. Prefilling reset-credential-choose-user screen with user '%s' ", existingUser.getUsername()); + context.setUser(existingUser); + Response challenge = context.form().createPasswordReset(); + context.challenge(challenge); + return; + } + + String actionTokenUserId = context.getAuthenticationSession().getAuthNote(DefaultActionTokenKey.ACTION_TOKEN_USER_ID); + if (actionTokenUserId != null) { + UserModel existingUser = context.getSession().users().getUserById(context.getRealm(), actionTokenUserId); + + // Action token logics handles checks for user ID validity and user being enabled + logger.debugf("Forget-password triggered when reauthenticating user after authentication via action token. Skipping reset-credential-choose-user screen and using user '%s' ", existingUser.getUsername()); + context.setUser(existingUser); + context.success(); + return; + } + context.challenge(challenge(context)); + } + + protected Response challenge(AuthenticationFlowContext context) { + // Write out KC_HTTP_RELATIVE_PATH environment variable to the form (for client side requests) + String relativePath = ""; + String envRelativePath = System.getenv("KC_HTTP_RELATIVE_PATH"); + if (envRelativePath != null && !envRelativePath.isEmpty()) { + relativePath = envRelativePath; + } + + logger.debug("KC_HTTP_RELATIVE_PATH1: " + relativePath); + + var form = context.form(); + return context.form() + .setAttribute(ATTRIBUTE_SUPPORT_PHONE, true) + .setAttribute("KC_HTTP_RELATIVE_PATH", relativePath) + .createPasswordReset(); + } + + @Override + public void action(AuthenticationFlowContext context) { + MultivaluedMap formData = context.getHttpRequest().getDecodedFormParameters(); + + if (!validateForm(context, formData)) { + return; + } + context.success(); + } + + protected boolean validateForm(AuthenticationFlowContext context, MultivaluedMap inputData) { + boolean byPhone = OptionalUtils + .ofBlank(inputData.getFirst(FIELD_PATH_PHONE_ACTIVATED)) + .map(s -> "true".equalsIgnoreCase(s) || "yes".equalsIgnoreCase(s)) + .orElse(false); + + context.clearUser(); + + String phoneNumber = inputData.getFirst(FIELD_PHONE_NUMBER); + String username = inputData.getFirst(AuthenticationManager.FORM_USERNAME); + + UserModel user; + if (!byPhone) { + if (Validation.isBlank(username)) { + context.getEvent().error(Errors.USERNAME_MISSING); + Response challenge = challenge(context, Validation.FIELD_USERNAME, Messages.MISSING_USERNAME); + context.failureChallenge(AuthenticationFlowError.INVALID_USER, challenge); + return false; + } + user = getUserByUsername(context, username.trim()); + } else { + if (Validation.isBlank(phoneNumber)) { + context.getEvent().error(Errors.USERNAME_MISSING); + Response challenge = challenge(context, FIELD_PHONE_NUMBER, SupportPhonePages.Errors.MISSING.message(), phoneNumber); + context.forceChallenge(challenge); + return false; + } + + try { + phoneNumber = Utils.canonicalizePhoneNumber(context.getSession(), phoneNumber); + } catch (PhoneNumberInvalidException e) { + context.getEvent().error(Errors.USERNAME_MISSING); + Response challenge = challenge(context, FIELD_PHONE_NUMBER, + e.getErrorType().message(), phoneNumber); + context.forceChallenge(challenge); + return false; + } + + String verificationCode = inputData.getFirst(FIELD_VERIFICATION_CODE); + if (Validation.isBlank(verificationCode)) { + invalidVerificationCode(context, phoneNumber); + return false; + } + user = Utils.findUserByPhone(context.getSession(), context.getRealm(), phoneNumber) + .orElse(null); + + if (user != null && !validateVerificationCode(context, user, phoneNumber, verificationCode.trim())) { + return false; + } + } + + return validateUser(context, user, byPhone, byPhone ? phoneNumber : username); + } + + protected UserModel getUserByUsername(AuthenticationFlowContext context, String username) { + RealmModel realm = context.getRealm(); + UserModel user = context.getSession().users().getUserByUsername(realm, username); + if (user == null && realm.isLoginWithEmailAllowed() && username.contains("@")) { + user = context.getSession().users().getUserByEmail(realm, username); + } + context.getAuthenticationSession().setAuthNote(AbstractUsernameFormAuthenticator.ATTEMPTED_USERNAME, username); + return user; + } + + protected boolean isDisabledByBruteForce(AuthenticationFlowContext context, UserModel user, String phoneNumber) { + String bruteForceError = getDisabledByBruteForceEventError(context, user); + if (bruteForceError != null) { + context.getEvent().user(user); + context.getEvent().error(bruteForceError); + Response challenge = challenge(context, FIELD_PHONE_NUMBER, Messages.INVALID_USER, phoneNumber); + context.forceChallenge(challenge); + return true; + } + return false; + } + + protected boolean isDisabledByBruteForce(AuthenticationFlowContext context, UserModel user) { + String bruteForceError = getDisabledByBruteForceEventError(context, user); + if (bruteForceError != null) { + context.getEvent().user(user); + context.getEvent().error(bruteForceError); + Response challenge = challenge(context, FIELD_PHONE_NUMBER, Messages.INVALID_USER); + context.forceChallenge(challenge); + return true; + } + return false; + } + + protected boolean validateUser(AuthenticationFlowContext context, + UserModel user, boolean byPhone, String attempted) { + + if (user == null) { + context.getEvent().error(Errors.USER_NOT_FOUND); + if (!byPhone) { + context.getEvent().detail(Details.USERNAME, attempted); + } + Response challenge = byPhone ? challenge(context, FIELD_PHONE_NUMBER, SupportPhonePages.Errors.USER_NOT_FOUND.message(), attempted) : challenge(context, Validation.FIELD_USERNAME, Messages.INVALID_USERNAME_OR_EMAIL); + context.forceChallenge(challenge); + return false; + } + + if (byPhone ? isDisabledByBruteForce(context, user, attempted) : isDisabledByBruteForce(context, user)) { + return false; + } + if (!user.isEnabled()) { + if (!byPhone) { + context.getEvent().detail(Details.USERNAME, attempted); + } + context.getEvent().user(user); + context.getEvent().error(Errors.USER_DISABLED); + Response challenge = byPhone ? challenge(context, FIELD_PHONE_NUMBER, Errors.USER_DISABLED, attempted) : challenge(context, Validation.FIELD_USERNAME, Errors.USER_DISABLED); + context.forceChallenge(challenge); + return false; + } + + if (byPhone) { + context.getAuthenticationSession().setAuthNote(SHOULD_SEND_EMAIL, "false"); + } + context.setUser(user); + return true; + } + + protected void invalidVerificationCode(AuthenticationFlowContext context, String phoneNumber) { + context.getEvent().error(Errors.INVALID_USER_CREDENTIALS); + Response challenge = challenge(context, FIELD_VERIFICATION_CODE, SupportPhonePages.Errors.NOT_MATCH.message(), phoneNumber); + context.failureChallenge(AuthenticationFlowError.INVALID_CREDENTIALS, challenge); + } + + protected Response challenge(AuthenticationFlowContext context, String field, String message, String phoneNumber) { + // Write out KC_HTTP_RELATIVE_PATH environment variable to the form (for client side requests) + String relativePath = ""; + String envRelativePath = System.getenv("KC_HTTP_RELATIVE_PATH"); + if (envRelativePath != null && !envRelativePath.isEmpty()) { + relativePath = envRelativePath; + } + + logger.debug("KC_HTTP_RELATIVE_PATH2: " + relativePath); + + return context.form() + .addError(new FormMessage(field, message)) + .setAttribute(ATTRIBUTE_SUPPORT_PHONE, true) + .setAttribute(ATTEMPTED_PHONE_ACTIVATED, true) + .setAttribute(ATTEMPTED_PHONE_NUMBER, phoneNumber) + .setAttribute("KC_HTTP_RELATIVE_PATH", relativePath) + .createPasswordReset(); + } + + protected Response challenge(AuthenticationFlowContext context, String field, String message) { + // Write out KC_HTTP_RELATIVE_PATH environment variable to the form (for client side requests) + String relativePath = ""; + String envRelativePath = System.getenv("KC_HTTP_RELATIVE_PATH"); + if (envRelativePath != null && !envRelativePath.isEmpty()) { + relativePath = envRelativePath; + } + logger.debug("KC_HTTP_RELATIVE_PATH3: " + relativePath); + + return context.form() + .addError(new FormMessage(field, message)) + .setAttribute(ATTRIBUTE_SUPPORT_PHONE, true) + .setAttribute("KC_HTTP_RELATIVE_PATH", relativePath) + .createPasswordReset(); + } + + private boolean validateVerificationCode(AuthenticationFlowContext context, UserModel user, String phoneNumber, String code) { + try { + context.getSession().getProvider(PhoneVerificationCodeProvider.class).validateCode(user, phoneNumber, code, TokenCodeType.RESET); + logger.debug("verification code success!"); + return true; + } catch (Exception e) { + logger.debug("verification code fail!"); + context.getEvent().user(user); + invalidVerificationCode(context, phoneNumber); + return false; + } + } + + @Override + public boolean requiresUser() { + return false; + } + + @Override + public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) { + return true; + } + + @Override + public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) { + + } + + @Override + public String getDisplayType() { + return "Reset Credential Choose User with Phone"; + } + + @Override + public String getReferenceCategory() { + return null; + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public Authenticator create(KeycloakSession session) { + return this; + } + + @Override + public void init(Config.Scope config) { + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + } + + @Override + public boolean isConfigurable() { + return false; + } + + public static final AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = { + AuthenticationExecutionModel.Requirement.REQUIRED + }; + + @Override + public AuthenticationExecutionModel.Requirement[] getRequirementChoices() { + return REQUIREMENT_CHOICES; + } + + @Override + public boolean isUserSetupAllowed() { + return false; + } + + @Override + public String getHelpText() { + return "Choose a user to reset credentials for"; + } + + @Override + public List getConfigProperties() { + return null; + } + + @Override + public void close() { + + } +} diff --git a/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/authentication/forms/RegistrationPhoneUserCreation.java b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/authentication/forms/RegistrationPhoneUserCreation.java new file mode 100644 index 000000000..4722601a7 --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/authentication/forms/RegistrationPhoneUserCreation.java @@ -0,0 +1,323 @@ +package cc.coopersoft.keycloak.phone.authentication.forms; + +import java.util.ArrayList; +import java.util.List; + +import org.jboss.logging.Logger; +import org.keycloak.Config; +import org.keycloak.authentication.FormAction; +import org.keycloak.authentication.FormActionFactory; +import org.keycloak.authentication.FormContext; +import org.keycloak.authentication.ValidationContext; +import org.keycloak.events.Details; +import org.keycloak.events.Errors; +import org.keycloak.events.EventType; +import org.keycloak.forms.login.LoginFormsProvider; +import org.keycloak.models.AuthenticationExecutionModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.models.utils.FormMessage; +import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.provider.ProviderConfigProperty; +import static org.keycloak.provider.ProviderConfigProperty.BOOLEAN_TYPE; +import org.keycloak.provider.ProviderConfigurationBuilder; +import org.keycloak.services.messages.Messages; +import org.keycloak.services.validation.Validation; +import org.keycloak.userprofile.UserProfile; +import org.keycloak.userprofile.UserProfileContext; +import org.keycloak.userprofile.UserProfileProvider; +import org.keycloak.userprofile.ValidationException; + +import cc.coopersoft.keycloak.phone.Utils; +import static cc.coopersoft.keycloak.phone.authentication.forms.SupportPhonePages.FIELD_PHONE_NUMBER; +import cc.coopersoft.keycloak.phone.providers.exception.PhoneNumberInvalidException; +import jakarta.ws.rs.core.MultivaluedMap; + +public class RegistrationPhoneUserCreation implements FormActionFactory, FormAction { + + private static final Logger logger = Logger.getLogger(RegistrationPhoneUserCreation.class); + + public static final String PROVIDER_ID = "registration-phone-username-creation"; + + public static final String CONFIG_PHONE_NUMBER_AS_USERNAME = "phoneNumberAsUsername"; + + public static final String CONFIG_INPUT_NAME = "isInputName"; + + public static final String CONFIG_INPUT_EMAIL = "isInputEmail"; + private static final AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = { + AuthenticationExecutionModel.Requirement.REQUIRED, AuthenticationExecutionModel.Requirement.DISABLED}; + + @Override + public String getDisplayType() { + return "Registration Phone User Creation"; + } + + @Override + public String getHelpText() { + return "This action must always be first And Do not use Email as username! registration phone number as username. In success phase, this will create the user in the database."; + } + + @Override + public String getReferenceCategory() { + return null; + } + + @Override + public boolean isConfigurable() { + return true; + } + + protected static final List CONFIG_PROPERTIES; + + static { + CONFIG_PROPERTIES = ProviderConfigurationBuilder.create() + .property().name(CONFIG_PHONE_NUMBER_AS_USERNAME) + .type(BOOLEAN_TYPE) + .label("Phone number as username") + .helpText("Allow users to set phone number as username. If Realm has `email as username` set to true, this is invalid!") + .defaultValue(true) + .add() + .build(); + } + + @Override + public AuthenticationExecutionModel.Requirement[] getRequirementChoices() { + return REQUIREMENT_CHOICES; + } + + @Override + public boolean isUserSetupAllowed() { + return false; + } + + @Override + public List getConfigProperties() { + return CONFIG_PROPERTIES; + } + + @Override + public FormAction create(KeycloakSession session) { + return this; + } + + @Override + public void init(Config.Scope config) { + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + + } + + @Override + public void close() { + + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + // FormAction + private boolean isPhoneNumberAsUsername(FormContext context) { + if (context.getAuthenticatorConfig() == null || "true".equals(context.getAuthenticatorConfig().getConfig() + .getOrDefault(CONFIG_PHONE_NUMBER_AS_USERNAME, "true"))) { + + if (context.getRealm().isRegistrationEmailAsUsername()) { + logger.warn("Realm set email as username, can't use phone number."); + return false; + } + + return true; + } + return false; + } + + @Override + public void buildPage(FormContext context, LoginFormsProvider form) { + if (isPhoneNumberAsUsername(context)) { + form.setAttribute("registrationPhoneNumberAsUsername", true); + } + + // write out KC_HTTP_RELATIVE_PATH environment variable to the form + String relativePath = ""; + String envRelativePath = System.getenv("KC_HTTP_RELATIVE_PATH"); + if (envRelativePath != null && !envRelativePath.isEmpty()) { + relativePath = envRelativePath; + } + form.setAttribute("KC_HTTP_RELATIVE_PATH", relativePath); + } + + @Override + public void validate(ValidationContext context) { + // Initialize session, form data, and errors list + KeycloakSession session = context.getSession(); + MultivaluedMap formData = context.getHttpRequest().getDecodedFormParameters(); + context.getEvent().detail(Details.REGISTER_METHOD, "form"); + String phoneNumber = formData.getFirst(FIELD_PHONE_NUMBER); + String email = formData.getFirst(UserModel.EMAIL); + boolean success = true; + List errors = new ArrayList<>(); + + // Check if both phoneNumber and email are blank + boolean isPhoneBlank = Validation.isBlank(phoneNumber); + boolean isEmailBlank = Validation.isBlank(email); + if (isPhoneBlank && isEmailBlank) { + String errorMsg = "Either phone number or email must be specified."; + errors.add(new FormMessage(FIELD_PHONE_NUMBER, errorMsg)); + errors.add(new FormMessage(UserModel.EMAIL, errorMsg)); + context.error(Errors.INVALID_REGISTRATION); + success = false; + } // If phone number is used as username, it must be specified + else if (isPhoneNumberAsUsername(context) && isPhoneBlank) { + String errorMsg = "Phone number must be specified when it is used as username."; + errors.add(new FormMessage(FIELD_PHONE_NUMBER, errorMsg)); + context.error(Errors.INVALID_REGISTRATION); + success = false; + } // Validate phone number if it's provided + else if (!isPhoneBlank) { + try { + // Ensure phone number starts with '+' + if (!phoneNumber.startsWith("+")) { + phoneNumber = "+" + phoneNumber; + } + + // Canonicalize phone number + phoneNumber = Utils.canonicalizePhoneNumber(session, phoneNumber); + + // Check for duplicate phone numbers + if (Utils.findUserByPhone(session, context.getRealm(), phoneNumber).isPresent()) { + errors.add(new FormMessage(FIELD_PHONE_NUMBER, SupportPhonePages.Errors.EXISTS.message())); + context.error(Errors.INVALID_REGISTRATION); + success = false; + } + } catch (PhoneNumberInvalidException e) { + errors.add(new FormMessage(FIELD_PHONE_NUMBER, e.getErrorType().message())); + context.error(Errors.INVALID_REGISTRATION); + success = false; + } + } + + // Determine username based on provided information and settings + String username; + if (isPhoneNumberAsUsername(context)) { + // Use phone number as username + username = phoneNumber; + } else if (!Validation.isBlank(email)) { + // Use email as username if provided, else use phone number + username = email; + } else { + username = phoneNumber; + } + + // Set the username in form data and event details + formData.putSingle(UserModel.USERNAME, username); + context.getEvent().detail(Details.USERNAME, username); + + // Get user profile provider and create user profile + UserProfileProvider profileProvider = session.getProvider(UserProfileProvider.class); + UserProfile profile = profileProvider.create(UserProfileContext.REGISTRATION_USER_CREATION, formData); + + // Retrieve other attributes from the profile + String emailFromProfile = profile.getAttributes().getFirstValue(UserModel.EMAIL); + + // Set event details + context.getEvent().detail(Details.EMAIL, emailFromProfile); + + // Validate the profile + try { + profile.validate(); + } catch (ValidationException pve) { + if (pve.hasError(Messages.EMAIL_EXISTS)) { + context.error(Errors.EMAIL_IN_USE); + } else if (pve.hasError(Messages.MISSING_EMAIL, Messages.MISSING_USERNAME, Messages.INVALID_EMAIL)) { + context.error(Errors.INVALID_REGISTRATION); + } else if (pve.hasError(Messages.USERNAME_EXISTS)) { + context.error(Errors.USERNAME_IN_USE); + } + success = false; + errors.addAll(Validation.getFormErrorsFromValidation(pve.getErrors())); + } + + // If validation was successful, mark context as success + if (success) { + context.success(); + } else { + // If validation failed, return errors + context.validationError(formData, errors); + } + } + + @Override + public void success(FormContext context) { + MultivaluedMap formData = context.getHttpRequest().getDecodedFormParameters(); + + String phoneNumber = formData.getFirst(FIELD_PHONE_NUMBER); + String email = formData.getFirst(UserModel.EMAIL); + String username = formData.getFirst(UserModel.USERNAME); // Already set during validation + + var session = context.getSession(); + if (!Validation.isBlank(phoneNumber)) { + + try { + // Canonicalize phone number again to ensure consistent format + phoneNumber = Utils.canonicalizePhoneNumber(session, phoneNumber); + } catch (PhoneNumberInvalidException e) { + // Phone number was already validated during the validation process + throw new IllegalStateException(); + } + } + + // Add details to the event + context.getEvent().detail(Details.USERNAME, username) + .detail(Details.REGISTER_METHOD, "form") + .detail(FIELD_PHONE_NUMBER, phoneNumber) + .detail(Details.EMAIL, email); // Always set email + + // Create the user profile and set the user + UserProfileProvider profileProvider = session.getProvider(UserProfileProvider.class); + UserProfile profile = profileProvider.create(UserProfileContext.REGISTRATION_USER_CREATION, formData); + UserModel user = profile.create(); + + user.setEnabled(true); + context.setUser(user); + + // Set additional session and event details + context.getAuthenticationSession().setClientNote(OIDCLoginProtocol.LOGIN_HINT_PARAM, username); + + context.getEvent().user(user); + context.getEvent().success(); + + // Start a new login event + context.newEvent().event(EventType.LOGIN); + context.getEvent().client(context.getAuthenticationSession().getClient().getClientId()) + .detail(Details.REDIRECT_URI, context.getAuthenticationSession().getRedirectUri()) + .detail(Details.AUTH_METHOD, context.getAuthenticationSession().getProtocol()); + + String authType = context.getAuthenticationSession().getAuthNote(Details.AUTH_TYPE); + if (authType != null) { + context.getEvent().detail(Details.AUTH_TYPE, authType); + } + + // Logging the user creation info + logger.info(String.format("User: %s is created, username is %s", user.getId(), user.getUsername())); + } + + @Override + public boolean requiresUser() { + return false; + } + + @Override + public boolean configuredFor(KeycloakSession keycloakSession, RealmModel realmModel, UserModel userModel) { + return true;//!realmModel.isRegistrationEmailAsUsername(); + } + + @Override + public void setRequiredActions(KeycloakSession keycloakSession, RealmModel realmModel, UserModel userModel) { + + } +} diff --git a/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/authentication/forms/RegistrationPhoneVerificationCode.java b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/authentication/forms/RegistrationPhoneVerificationCode.java new file mode 100644 index 000000000..5fb22b039 --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/authentication/forms/RegistrationPhoneVerificationCode.java @@ -0,0 +1,292 @@ +package cc.coopersoft.keycloak.phone.authentication.forms; + +import java.util.ArrayList; +import java.util.List; + +import org.jboss.logging.Logger; +import org.keycloak.Config; +import org.keycloak.authentication.FormAction; +import org.keycloak.authentication.FormActionFactory; +import org.keycloak.authentication.FormContext; +import org.keycloak.authentication.ValidationContext; +import org.keycloak.credential.CredentialProvider; +import org.keycloak.events.Details; +import org.keycloak.events.Errors; +import org.keycloak.forms.login.LoginFormsProvider; +import org.keycloak.models.AuthenticationExecutionModel.Requirement; +import org.keycloak.models.AuthenticatorConfigModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.models.utils.FormMessage; +import org.keycloak.provider.ProviderConfigProperty; +import static org.keycloak.provider.ProviderConfigProperty.BOOLEAN_TYPE; +import org.keycloak.provider.ProviderConfigurationBuilder; +import org.keycloak.services.validation.Validation; +import org.keycloak.sessions.AuthenticationSessionModel; + +import cc.coopersoft.keycloak.phone.Utils; +import static cc.coopersoft.keycloak.phone.authentication.forms.SupportPhonePages.FIELD_PHONE_NUMBER; +import static cc.coopersoft.keycloak.phone.authentication.forms.SupportPhonePages.FIELD_VERIFICATION_CODE; +import cc.coopersoft.keycloak.phone.credential.PhoneOtpCredentialModel; +import cc.coopersoft.keycloak.phone.credential.PhoneOtpCredentialProvider; +import cc.coopersoft.keycloak.phone.credential.PhoneOtpCredentialProviderFactory; +import cc.coopersoft.keycloak.phone.providers.constants.TokenCodeType; +import cc.coopersoft.keycloak.phone.providers.exception.PhoneNumberInvalidException; +import cc.coopersoft.keycloak.phone.providers.representations.TokenCodeRepresentation; +import cc.coopersoft.keycloak.phone.providers.spi.PhoneVerificationCodeProvider; +import jakarta.ws.rs.core.MultivaluedMap; + +public class RegistrationPhoneVerificationCode implements FormAction, FormActionFactory { + + private static final Logger logger = Logger.getLogger(RegistrationPhoneVerificationCode.class); + + public static final String PROVIDER_ID = "registration-phone"; + + public static final String CONFIG_OPT_CREDENTIAL = "createOPTCredential"; + + @Override + public String getHelpText() { + return "valid phone number and verification code"; + } + + @Override + public boolean isUserSetupAllowed() { + return false; + } + + @Override + public void close() { + + } + + @Override + public String getDisplayType() { + return "Phone validation"; + } + + @Override + public String getReferenceCategory() { + return null; + } + + @Override + public boolean isConfigurable() { + return true; + } + + protected static final List CONFIG_PROPERTIES; + + static { + CONFIG_PROPERTIES = ProviderConfigurationBuilder.create() + .property().name(CONFIG_OPT_CREDENTIAL) + .type(BOOLEAN_TYPE) + .defaultValue(false) + .label("Create OTP Credential") + .helpText("Create OTP credential by phone number.") + .add() + .build(); + } + + @Override + public List getConfigProperties() { + return CONFIG_PROPERTIES; + } + + private final static Requirement[] REQUIREMENT_CHOICES = { + Requirement.REQUIRED, Requirement.DISABLED}; + + @Override + public Requirement[] getRequirementChoices() { + return REQUIREMENT_CHOICES; + } + + @Override + public FormAction create(KeycloakSession session) { + return this; + } + + @Override + public void init(Config.Scope config) { + + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + // FormAction + private PhoneVerificationCodeProvider getTokenCodeService(KeycloakSession session) { + return session.getProvider(PhoneVerificationCodeProvider.class); + } + + @Override + public void validate(ValidationContext context) { + // Extract form data and initialize errors list + MultivaluedMap formData = context.getHttpRequest().getDecodedFormParameters(); + List errors = new ArrayList<>(); + context.getEvent().detail(Details.REGISTER_METHOD, "form"); + + // Get session and phone number from the form data + KeycloakSession session = context.getSession(); + AuthenticationSessionModel authSession = context.getAuthenticationSession(); + String phoneNumber = formData.getFirst(FIELD_PHONE_NUMBER); + + // Log initial validation step for phone number + logger.info("Validating phone number during registration: " + phoneNumber); + + // Check if phone number is blank + if (Validation.isBlank(phoneNumber)) { + authSession.setAuthNote("phoneVerified", "false"); + authSession.setAuthNote("verifiedPhoneNumber", null); + context.success(); + logger.info("Phone number is blank, skipping validation."); + return; + } + + // Check if phone number is different from previously verified phone number + String verifiedPhoneNumber = authSession.getAuthNote("verifiedPhoneNumber"); + if (!phoneNumber.equals(verifiedPhoneNumber)) { + authSession.setAuthNote("phoneVerified", "false"); + authSession.setAuthNote("verifiedPhoneNumber", null); + } + + // Validate phone number + if (!validatePhoneNumber(context, session, authSession, phoneNumber, errors, formData)) { + authSession.setAuthNote("phoneVerified", "false"); + authSession.setAuthNote("verifiedPhoneNumber", null); + return; + } + + // Validate verification code if phone is not verified + if (!Boolean.parseBoolean(authSession.getAuthNote("phoneVerified")) && !validateVerificationCode(context, session, authSession, phoneNumber, errors, formData)) { + authSession.setAuthNote("phoneVerified", "false"); + authSession.setAuthNote("verifiedPhoneNumber", null); + return; + } + + // Mark the context as successful + context.success(); + logger.info("Validation completed successfully for phone number: " + phoneNumber); + } + + private boolean validatePhoneNumber(ValidationContext context, KeycloakSession session, AuthenticationSessionModel authSession, String phoneNumber, List errors, MultivaluedMap formData) { + try { + if (!phoneNumber.startsWith("+")) { + phoneNumber = "+" + phoneNumber; + } + phoneNumber = Utils.canonicalizePhoneNumber(session, phoneNumber); + logger.info("Canonicalized phone number: " + phoneNumber); + } catch (PhoneNumberInvalidException e) { + logger.error("Invalid phone number: " + phoneNumber + ", error: " + e.getMessage()); + context.error(Errors.INVALID_REGISTRATION); + errors.add(new FormMessage(FIELD_PHONE_NUMBER, e.getErrorType().message())); + context.validationError(formData, errors); + return false; + } + + context.getEvent().detail(FIELD_PHONE_NUMBER, phoneNumber); + authSession.setAuthNote("verifiedPhoneNumber", phoneNumber); + return true; + } + + private boolean validateVerificationCode(ValidationContext context, KeycloakSession session, AuthenticationSessionModel authSession, String phoneNumber, List errors, MultivaluedMap formData) { + String verificationCode = formData.getFirst(FIELD_VERIFICATION_CODE); + logger.info("Verification code entered: " + verificationCode); + + TokenCodeRepresentation tokenCode = getTokenCodeService(session).ongoingProcess(phoneNumber, TokenCodeType.REGISTRATION); + if (Validation.isBlank(verificationCode) || tokenCode == null || !tokenCode.getCode().equals(verificationCode)) { + logger.warn("Verification code mismatch or not found for phone number: " + phoneNumber); + context.error(Errors.INVALID_REGISTRATION); + formData.remove(FIELD_VERIFICATION_CODE); + errors.add(new FormMessage(FIELD_VERIFICATION_CODE, SupportPhonePages.Errors.NOT_MATCH.message())); + context.validationError(formData, errors); + return false; + } + + authSession.setAuthNote("tokenId", tokenCode.getId()); + logger.info("Phone number verified successfully. Token ID stored in session: " + tokenCode.getId()); + authSession.setAuthNote("phoneVerified", "true"); + authSession.setAuthNote("verifiedPhoneNumber", phoneNumber); + return true; + } + + @Override + public void buildPage(FormContext context, LoginFormsProvider form) { + AuthenticationSessionModel authSession = context.getAuthenticationSession(); + boolean phoneVerified = Boolean.parseBoolean(authSession.getAuthNote("phoneVerified")); + + // Extract form data and initialize errors list + MultivaluedMap formData = context.getHttpRequest().getDecodedFormParameters(); + + // Retrieve the phone number from the form + String phoneNumber = formData.getFirst(FIELD_PHONE_NUMBER); + + // Retrieve the verified phone number from the authentication session + String verifiedPhoneNumber = authSession.getAuthNote("verifiedPhoneNumber"); + + // Check if the phone numbers match + if (phoneNumber == null || !phoneNumber.equals(verifiedPhoneNumber)) { + phoneVerified = false; + authSession.setAuthNote("verifiedPhoneNumber", null); + } + + form.setAttribute("verifyPhone", true); + form.setAttribute("phoneVerified", phoneVerified); + } + + @Override + public void success(FormContext context) { + UserModel user = context.getUser(); + AuthenticationSessionModel authSession = context.getAuthenticationSession(); + + MultivaluedMap formData = context.getHttpRequest().getDecodedFormParameters(); + + String phoneNumber = formData.getFirst(FIELD_PHONE_NUMBER); + + if (Validation.isBlank(phoneNumber)) { + return; + } + + try { + phoneNumber = Utils.canonicalizePhoneNumber(context.getSession(), phoneNumber); + } catch (PhoneNumberInvalidException e) { + // verified in validate process + throw new IllegalStateException(); + } + String tokenId = authSession.getAuthNote("tokenId"); + + logger.info(String.format("registration user %s phone success, tokenId is: %s", user.getId(), tokenId)); + getTokenCodeService(context.getSession()).tokenValidated(user, phoneNumber, tokenId, false); + + AuthenticatorConfigModel config = context.getAuthenticatorConfig(); + if (config != null + && "true".equalsIgnoreCase(config.getConfig().getOrDefault(CONFIG_OPT_CREDENTIAL, "false"))) { + PhoneOtpCredentialProvider ocp = (PhoneOtpCredentialProvider) context.getSession() + .getProvider(CredentialProvider.class, PhoneOtpCredentialProviderFactory.PROVIDER_ID); + ocp.createCredential(context.getRealm(), context.getUser(), PhoneOtpCredentialModel.create(phoneNumber, tokenId, 0)); + } + } + + @Override + public boolean requiresUser() { + return false; + } + + @Override + public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) { + return true; + } + + @Override + public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) { + + } +} diff --git a/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/authentication/forms/RegistrationRedirectParametersReader.java b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/authentication/forms/RegistrationRedirectParametersReader.java new file mode 100644 index 000000000..f9609bb07 --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/authentication/forms/RegistrationRedirectParametersReader.java @@ -0,0 +1,179 @@ +package cc.coopersoft.keycloak.phone.authentication.forms; + +import okhttp3.HttpUrl; +import org.jboss.logging.Logger; +import org.keycloak.Config; +import org.keycloak.authentication.FormAction; +import org.keycloak.authentication.FormActionFactory; +import org.keycloak.authentication.FormContext; +import org.keycloak.authentication.ValidationContext; +import org.keycloak.forms.login.LoginFormsProvider; +import org.keycloak.models.*; +import org.keycloak.provider.ProviderConfigProperty; +import org.keycloak.services.validation.Validation; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.keycloak.provider.ProviderConfigProperty.MULTIVALUED_STRING_TYPE; + +public class RegistrationRedirectParametersReader implements FormActionFactory, FormAction { + + private static final Logger logger = Logger.getLogger(RegistrationRedirectParametersReader.class); + + public static final String PROVIDER_ID = "registration-redirect-parameter"; + public static final String PARAM_NAMES = "acceptParameter"; + + + private static AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = { + AuthenticationExecutionModel.Requirement.REQUIRED, AuthenticationExecutionModel.Requirement.DISABLED}; + + + @Override + public String getDisplayType() { + return "Redirect parameter reader"; + } + + @Override + public String getReferenceCategory() { + return null; + } + + @Override + public boolean isConfigurable() { + return true; + } + + @Override + public List getConfigProperties() { + ProviderConfigProperty rep = + new ProviderConfigProperty(PARAM_NAMES, + "Accept query param", + "Registration query param accept names.", + MULTIVALUED_STRING_TYPE, null); + return Collections.singletonList(rep); + } + + @Override + public AuthenticationExecutionModel.Requirement[] getRequirementChoices() { + return REQUIREMENT_CHOICES; + } + + @Override + public boolean isUserSetupAllowed() { + return false; + } + + @Override + public String getHelpText() { + return "Read query parameter add to user attribute"; + } + + @Override + public FormAction create(KeycloakSession session) { + return this; + } + + @Override + public void init(Config.Scope config) { + + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + + } + + @Override + public void close() { + + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + // FormAction + + @Override + public void buildPage(FormContext formContext, LoginFormsProvider loginFormsProvider) { + } + + @Override + public void validate(ValidationContext validationContext) { + validationContext.success(); + } + + @Override + public void success(FormContext context) { + + + String redirectUri = context.getAuthenticationSession().getRedirectUri(); + logger.info("add user attribute form redirectUri:" + redirectUri); + if (Validation.isBlank(redirectUri)) { + logger.error("no referer. cant get param in keycloak version"); + return; + } + + HttpUrl url = HttpUrl.parse(redirectUri); + if (url == null) { + logger.error("redirectUri is null"); + return; + } + //url.queryParameterNames().forEach(s -> logger.info("redirect param name ->" + s)); + UserModel user = context.getUser(); + AuthenticatorConfigModel authenticatorConfig = context.getAuthenticatorConfig(); + + if (authenticatorConfig == null || authenticatorConfig.getConfig() == null) { + logger.error("can't get config!"); + return; + } + + String params = authenticatorConfig.getConfig().get(PARAM_NAMES); + + if (Validation.isBlank(params)) { + logger.warn("accept params is not configure."); + return; + } + + logger.info("allow query param names:" + params); + + List finalParamNames = new ArrayList<>(); + + Pattern p = Pattern.compile("[A-Za-z_]\\w*"); + Matcher m = p.matcher(params); + while (m.find()) { + finalParamNames.add(m.group()); + } + + if (finalParamNames.isEmpty()) { + logger.warn("accept params is not configure."); + return; + } + + url.queryParameterNames() + .stream() + .filter(finalParamNames::contains) + .forEach(v -> user.setAttribute(v, url.queryParameterValues(v))); + + } + + @Override + public boolean requiresUser() { + return false; + } + + @Override + public boolean configuredFor(KeycloakSession keycloakSession, RealmModel realmModel, UserModel userModel) { + return true; + } + + @Override + public void setRequiredActions(KeycloakSession keycloakSession, RealmModel realmModel, UserModel userModel) { + + } +} diff --git a/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/authentication/forms/SupportPhonePages.java b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/authentication/forms/SupportPhonePages.java new file mode 100644 index 000000000..28214b919 --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/authentication/forms/SupportPhonePages.java @@ -0,0 +1,44 @@ +package cc.coopersoft.keycloak.phone.authentication.forms; + +public class SupportPhonePages { + + public enum Errors { + MISSING("requiredPhoneNumber"), + USER_NOT_FOUND("phoneUserNotFound"), + NUMBER_INVALID("invalidPhoneNumber"), + NO_PROCESS("noOngoingVerificationProcess"), + EXISTS("phoneNumberExists"), + ABUSED("abusedMessageService"), + NOT_MATCH("phoneTokenCodeDoesNotMatch"), + FAIL("sendVerificationCodeFail"); + + private final String errorMessage; + + public String message() { + return errorMessage; + } + + Errors(String message) { + this.errorMessage = message; + } + } + + public static final String ATTRIBUTE_SUPPORT_PHONE = "supportPhone"; + + public static final String FIELD_PHONE_NUMBER = "phoneNumber"; + + public static final String FIELD_VERIFICATION_CODE = "code"; + + public static final String FIELD_PATH_PHONE_ACTIVATED = "phoneActivated"; + + public static final String FIELD_PHONE_NUMBER_AS_USERNAME = "phoneNumberAsUsername"; + + public static final String ATTEMPTED_PHONE_NUMBER = "attemptedPhoneNumber"; + + public static final String ATTEMPTED_PHONE_ACTIVATED = "attemptedPhoneActivated"; + + public static final String ATTEMPTED_PHONE_NUMBER_AS_USERNAME = "attemptedPhoneNumberAsUsername"; + +// public static final String MESSAGE_PHONE_USER_NOT_FOUND = "phoneUserNotFound"; +// public static final String MESSAGE_VERIFICATION_CODE_NOT_MATCH = "verificationCodeDoesNotMatch"; +} diff --git a/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/authentication/requiredactions/ConfigSmsOtpRequiredAction.java b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/authentication/requiredactions/ConfigSmsOtpRequiredAction.java new file mode 100644 index 000000000..796e893d5 --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/authentication/requiredactions/ConfigSmsOtpRequiredAction.java @@ -0,0 +1,93 @@ +package cc.coopersoft.keycloak.phone.authentication.requiredactions; + +import cc.coopersoft.keycloak.phone.Utils; +import cc.coopersoft.keycloak.phone.authentication.forms.SupportPhonePages; +import cc.coopersoft.keycloak.phone.credential.PhoneOtpCredentialModel; +import cc.coopersoft.keycloak.phone.credential.PhoneOtpCredentialProvider; +import cc.coopersoft.keycloak.phone.credential.PhoneOtpCredentialProviderFactory; +import cc.coopersoft.keycloak.phone.providers.constants.TokenCodeType; +import cc.coopersoft.keycloak.phone.providers.exception.PhoneNumberInvalidException; +import cc.coopersoft.keycloak.phone.providers.spi.PhoneVerificationCodeProvider; +import jakarta.ws.rs.BadRequestException; +import jakarta.ws.rs.ForbiddenException; +import jakarta.ws.rs.core.Response; +import org.keycloak.authentication.RequiredActionContext; +import org.keycloak.authentication.RequiredActionProvider; +import org.keycloak.credential.CredentialProvider; + +import java.util.Optional; + +import static cc.coopersoft.keycloak.phone.authentication.authenticators.browser.PhoneUsernamePasswordForm.VERIFIED_PHONE_NUMBER; + +public class ConfigSmsOtpRequiredAction implements RequiredActionProvider { + + public static final String PROVIDER_ID = "CONFIGURE_SMS_OTP"; + + @Override + public void evaluateTriggers(RequiredActionContext context) { + } + +// private Optional getOTPCredential(RequiredActionContext context){ +// return Optional.ofNullable(context.getUser()) +// .flatMap(user -> user.credentialManager().getStoredCredentialsByTypeStream(PhoneOtpCredentialModel.TYPE).findFirst()) +// .map(credentialModel -> (PhoneOtpCredentialModel) credentialModel); +// } + + @Override + public void requiredActionChallenge(RequiredActionContext context) { + + var userPhoneNumber = PhoneOtpCredentialModel.getSmsOtpCredentialData(context.getUser()) + .map(PhoneOtpCredentialModel.SmsOtpCredentialData::getPhoneNumber) + .orElseGet(() -> Optional.ofNullable(context.getUser()) + .flatMap(user -> Optional.ofNullable(user.getFirstAttribute(SupportPhonePages.FIELD_PHONE_NUMBER))) + .orElse(null) + ); + + Response challenge = context.form() + .setAttribute(SupportPhonePages.FIELD_PHONE_NUMBER, userPhoneNumber) + .createForm("login-sms-otp-config.ftl"); + context.challenge(challenge); + } + + @Override + public void processAction(RequiredActionContext context) { + var session = context.getSession(); + PhoneVerificationCodeProvider phoneVerificationCodeProvider = session.getProvider(PhoneVerificationCodeProvider.class); + String phoneNumber = context.getHttpRequest().getDecodedFormParameters().getFirst(SupportPhonePages.FIELD_PHONE_NUMBER); + String code = context.getHttpRequest().getDecodedFormParameters().getFirst(SupportPhonePages.FIELD_VERIFICATION_CODE); + try { + phoneNumber = Utils.canonicalizePhoneNumber(context.getSession(),phoneNumber); + phoneVerificationCodeProvider.validateCode(context.getUser(), phoneNumber, code, TokenCodeType.OTP); + + PhoneOtpCredentialProvider ocp = (PhoneOtpCredentialProvider) context.getSession() + .getProvider(CredentialProvider.class, PhoneOtpCredentialProviderFactory.PROVIDER_ID); + ocp.createCredential(context.getRealm(), context.getUser(), + PhoneOtpCredentialModel.create(phoneNumber,code,Utils.getOtpExpires(context.getSession()))); + context.getAuthenticationSession().setAuthNote(VERIFIED_PHONE_NUMBER, phoneNumber); + context.success(); + } catch (BadRequestException e) { + + Response challenge = context.form() + .setError(SupportPhonePages.Errors.NO_PROCESS.message()) + .createForm("login-sms-otp-config.ftl"); + context.challenge(challenge); + + } catch (ForbiddenException e) { + + Response challenge = context.form() + .setAttribute("phoneNumber", phoneNumber) + .setError(SupportPhonePages.Errors.NOT_MATCH.message()) + .createForm("login-sms-otp-config.ftl"); + context.challenge(challenge); + } catch (PhoneNumberInvalidException e) { + Response challenge = context.form() + .setError(e.getErrorType().message()) + .createForm("login-sms-otp-config.ftl"); + context.challenge(challenge); + } + } + + @Override + public void close() { + } +} diff --git a/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/authentication/requiredactions/ConfigSmsOtpRequiredActionFactory.java b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/authentication/requiredactions/ConfigSmsOtpRequiredActionFactory.java new file mode 100644 index 000000000..b85287bbc --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/authentication/requiredactions/ConfigSmsOtpRequiredActionFactory.java @@ -0,0 +1,39 @@ +package cc.coopersoft.keycloak.phone.authentication.requiredactions; + +import org.keycloak.Config.Scope; +import org.keycloak.authentication.RequiredActionFactory; +import org.keycloak.authentication.RequiredActionProvider; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; + +public class ConfigSmsOtpRequiredActionFactory implements RequiredActionFactory { + + private static final ConfigSmsOtpRequiredAction instance = new ConfigSmsOtpRequiredAction(); + + @Override + public String getDisplayText() { + return "Configure OTP over SMS"; + } + + @Override + public RequiredActionProvider create(KeycloakSession session) { + return instance; + } + + @Override + public void init(Scope scope) { + } + + @Override + public void postInit(KeycloakSessionFactory sessionFactory) { + } + + @Override + public void close() { + } + + @Override + public String getId() { + return ConfigSmsOtpRequiredAction.PROVIDER_ID; + } +} diff --git a/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/authentication/requiredactions/UpdatePhoneNumberRequiredAction.java b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/authentication/requiredactions/UpdatePhoneNumberRequiredAction.java new file mode 100644 index 000000000..57ccd7d46 --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/authentication/requiredactions/UpdatePhoneNumberRequiredAction.java @@ -0,0 +1,149 @@ +package cc.coopersoft.keycloak.phone.authentication.requiredactions; + +import org.keycloak.authentication.RequiredActionContext; +import org.keycloak.authentication.RequiredActionProvider; +import org.keycloak.events.EventType; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakTransaction; +import org.keycloak.models.UserModel; +import org.keycloak.services.validation.Validation; + +import cc.coopersoft.keycloak.phone.Utils; +import cc.coopersoft.keycloak.phone.authentication.forms.SupportPhonePages; +import cc.coopersoft.keycloak.phone.providers.exception.PhoneNumberInvalidException; +import cc.coopersoft.keycloak.phone.providers.spi.PhoneVerificationCodeProvider; +import jakarta.ws.rs.BadRequestException; +import jakarta.ws.rs.ForbiddenException; +import jakarta.ws.rs.core.Response; + +public class UpdatePhoneNumberRequiredAction implements RequiredActionProvider { + + public static final String PROVIDER_ID = "UPDATE_PHONE_NUMBER"; + + @Override + public void evaluateTriggers(RequiredActionContext context) { + } + + @Override + public void requiredActionChallenge(RequiredActionContext context) { + // Write out KC_HTTP_RELATIVE_PATH environment variable to the form (for client side requests) + String relativePath = ""; + String envRelativePath = System.getenv("KC_HTTP_RELATIVE_PATH"); + if (envRelativePath != null && !envRelativePath.isEmpty()) { + relativePath = envRelativePath; + } + + Response challenge = context.form() + .setAttribute("KC_HTTP_RELATIVE_PATH", relativePath) + .createForm("login-update-phone-number.ftl"); + context.challenge(challenge); + } + + @Override + public void processAction(RequiredActionContext context) { + PhoneVerificationCodeProvider phoneVerificationCodeProvider = context.getSession().getProvider(PhoneVerificationCodeProvider.class); + String phoneNumber = context.getHttpRequest().getDecodedFormParameters().getFirst(SupportPhonePages.FIELD_PHONE_NUMBER); + String code = context.getHttpRequest().getDecodedFormParameters().getFirst(SupportPhonePages.FIELD_VERIFICATION_CODE); + + // Extract the email from the user profile + String email = context.getUser().getEmail(); + // get user's current phone number + String currentPhoneNumber = context.getUser().getFirstAttribute("phoneNumber"); + + KeycloakSession session = context.getSession(); + + try { + // Canonicalize phone number + final String canonicalPhoneNumber = Utils.canonicalizePhoneNumber(context.getSession(), phoneNumber); + + // Check for duplicate phone numbers if user's phone number has changed + if (!canonicalPhoneNumber.equals(currentPhoneNumber) && Utils.findUserByPhone(session, context.getRealm(), canonicalPhoneNumber).isPresent()) { + // Handle duplicate phone number + Response challenge = context.form() + .setAttribute("phoneNumber", canonicalPhoneNumber) + .setError(SupportPhonePages.Errors.EXISTS.message()) + .createForm("login-update-phone-number.ftl"); + context.challenge(challenge); + return; + } + + // Validate the verification code + phoneVerificationCodeProvider.validateCode(context.getUser(), canonicalPhoneNumber, code); + + // If email is blank, set the username to the phone number + if (Validation.isBlank(email)) { + UserModel user = context.getUser(); + user.setUsername(canonicalPhoneNumber); + } + + // Register a transaction listener to fire the UPDATE_PROFILE event after the transaction is committed + // Get the KeycloakTransactionManager and register the transaction completion listener + session.getTransactionManager().enlistAfterCompletion(new KeycloakTransaction() { + @Override + public void begin() { + // Optional: No action required at the beginning of the transaction + } + + @Override + public void commit() { + + // Fire the UPDATE_PROFILE event after the transaction is committed + context.getEvent().event(EventType.UPDATE_PROFILE) + .client(context.getAuthenticationSession().getClient()) + .user(context.getUser()) + .realm(context.getRealm()) + .detail("updated_phone_number", canonicalPhoneNumber) // Add any relevant details + .success(); + } + + @Override + public void rollback() { + // Optional: handle rollback if necessary + } + + @Override + public void setRollbackOnly() { + // Optional: handle rollback-only case + } + + @Override + public boolean getRollbackOnly() { + return false; + } + + @Override + public boolean isActive() { + return true; + } + }); + + context.success(); + } catch (BadRequestException e) { + // Handle bad request + Response challenge = context.form() + .setError(SupportPhonePages.Errors.NO_PROCESS.message()) + .createForm("login-update-phone-number.ftl"); + context.challenge(challenge); + + } catch (ForbiddenException e) { + // Handle forbidden error (wrong code) + Response challenge = context.form() + .setAttribute("phoneNumber", phoneNumber) + .setError(SupportPhonePages.Errors.NOT_MATCH.message()) + .createForm("login-update-phone-number.ftl"); + context.challenge(challenge); + + } catch (PhoneNumberInvalidException e) { + // Handle invalid phone number + Response challenge = context.form() + .setAttribute("phoneNumber", phoneNumber) + .setError(e.getErrorType().message()) + .createForm("login-update-phone-number.ftl"); + context.challenge(challenge); + } + } + + @Override + public void close() { + } +} diff --git a/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/authentication/requiredactions/UpdatePhoneNumberRequiredActionFactory.java b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/authentication/requiredactions/UpdatePhoneNumberRequiredActionFactory.java new file mode 100644 index 000000000..e94578ca3 --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/authentication/requiredactions/UpdatePhoneNumberRequiredActionFactory.java @@ -0,0 +1,39 @@ +package cc.coopersoft.keycloak.phone.authentication.requiredactions; + +import org.keycloak.Config.Scope; +import org.keycloak.authentication.RequiredActionFactory; +import org.keycloak.authentication.RequiredActionProvider; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; + +public class UpdatePhoneNumberRequiredActionFactory implements RequiredActionFactory { + + private static final UpdatePhoneNumberRequiredAction instance = new UpdatePhoneNumberRequiredAction(); + + @Override + public String getDisplayText() { + return "Update Phone Number"; + } + + @Override + public RequiredActionProvider create(KeycloakSession session) { + return instance; + } + + @Override + public void init(Scope scope) { + } + + @Override + public void postInit(KeycloakSessionFactory sessionFactory) { + } + + @Override + public void close() { + } + + @Override + public String getId() { + return UpdatePhoneNumberRequiredAction.PROVIDER_ID; + } +} diff --git a/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/credential/PhoneOtpCredentialModel.java b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/credential/PhoneOtpCredentialModel.java new file mode 100644 index 000000000..e7c94ee9b --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/credential/PhoneOtpCredentialModel.java @@ -0,0 +1,141 @@ +package cc.coopersoft.keycloak.phone.credential; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import org.keycloak.common.util.Time; +import org.keycloak.credential.CredentialModel; +import org.keycloak.models.UserModel; +import org.keycloak.models.credential.dto.OTPSecretData; +import org.keycloak.util.JsonSerialization; + +import java.io.IOException; +import java.util.Date; +import java.util.Optional; + +public class PhoneOtpCredentialModel extends CredentialModel { + + public static final String TYPE = "phone-otp"; + private final SmsOtpCredentialData credentialData; + private final OTPSecretData secretData; + + public PhoneOtpCredentialModel(SmsOtpCredentialData credentialData, OTPSecretData secretData) { + this.credentialData = credentialData; + this.secretData = secretData; + } + + private static Optional getOtpCredentialModel(@NotNull UserModel user) { + return user.credentialManager() + .getStoredCredentialsByTypeStream(PhoneOtpCredentialModel.TYPE).findFirst(); + } + + public static Optional getSmsOtpCredentialData(@NotNull UserModel user) { + return getOtpCredentialModel(user) + .map(credentialModel -> { + try { + return JsonSerialization.readValue(credentialModel.getCredentialData(), PhoneOtpCredentialModel.SmsOtpCredentialData.class); + } catch (IOException e) { + throw new IllegalArgumentException(e); + } + }); + } + + public static void updateOtpCredential(@NotNull UserModel user, + @NotNull PhoneOtpCredentialModel.SmsOtpCredentialData credentialData, + String secretValue) { + getOtpCredentialModel(user) + .ifPresent(credential -> { + try { + credential.setCredentialData(JsonSerialization.writeValueAsString(credentialData)); + credential.setSecretData(JsonSerialization.writeValueAsString(new OTPSecretData(secretValue))); + PhoneOtpCredentialModel credentialModel = PhoneOtpCredentialModel.createFromCredentialModel(credential); + user.credentialManager().updateStoredCredential(credentialModel); + } catch (IOException ioe) { + throw new RuntimeException(ioe); + } + }); + } + + public static PhoneOtpCredentialModel create(String phoneNumber, String secretValue, int expires) { + SmsOtpCredentialData credentialData = new SmsOtpCredentialData(phoneNumber, expires); + OTPSecretData secretData = new OTPSecretData(secretValue); + PhoneOtpCredentialModel credentialModel = new PhoneOtpCredentialModel(credentialData, secretData); + credentialModel.fillCredentialModelFields(); + return credentialModel; + } + + public static PhoneOtpCredentialModel createFromCredentialModel(CredentialModel credentialModel) { + + try { + SmsOtpCredentialData credentialData = JsonSerialization.readValue(credentialModel.getCredentialData(), SmsOtpCredentialData.class); + OTPSecretData secretData = JsonSerialization.readValue(credentialModel.getSecretData(), OTPSecretData.class); + PhoneOtpCredentialModel credential = new PhoneOtpCredentialModel(credentialData, secretData); + + credential.setUserLabel(credentialModel.getUserLabel()); + credential.setCreatedDate(credentialModel.getCreatedDate()); + credential.setType(TYPE); + credential.setId(credentialModel.getId()); + credential.setSecretData(credentialModel.getSecretData()); + credential.setCredentialData(credentialModel.getCredentialData()); + + return credential; + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private void fillCredentialModelFields() { + try { + setCredentialData(JsonSerialization.writeValueAsString(credentialData)); + setSecretData(JsonSerialization.writeValueAsString(secretData)); + setType(TYPE); + setCreatedDate(Time.currentTimeMillis()); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + public SmsOtpCredentialData getOTPCredentialData() { + return credentialData; + } + + public OTPSecretData getOTPSecretData() { + return secretData; + } + + @Getter + public static class SmsOtpCredentialData { + + private final String phoneNumber; + + private final long secretCreate; + + private final int expires; + + @JsonIgnore + public boolean isSecretInvalid() { + if (expires <= 0) { + return true; + } + return new Date().getTime() > expires * 1000L + secretCreate; + } + + @JsonCreator + //@ConstructorProperties("phoneNumber") + public SmsOtpCredentialData(@JsonProperty("phoneNumber") String phoneNumber, + @JsonProperty("expires") int expires) { + this.phoneNumber = phoneNumber; + this.secretCreate = new Date().getTime(); + this.expires = expires; + } + + public String getPhoneNumber() { + return phoneNumber; + } + } + + public static class EmptySecretData { + } +} diff --git a/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/credential/PhoneOtpCredentialProvider.java b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/credential/PhoneOtpCredentialProvider.java new file mode 100644 index 000000000..a4ae9dd88 --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/credential/PhoneOtpCredentialProvider.java @@ -0,0 +1,138 @@ +package cc.coopersoft.keycloak.phone.credential; + +import cc.coopersoft.keycloak.phone.authentication.authenticators.browser.SmsOtpMfaAuthenticatorFactory; +import cc.coopersoft.keycloak.phone.providers.constants.TokenCodeType; +import cc.coopersoft.keycloak.phone.providers.spi.PhoneVerificationCodeProvider; +import org.jboss.logging.Logger; +import org.keycloak.common.util.ObjectUtil; +import org.keycloak.common.util.Time; +import org.keycloak.credential.*; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserCredentialModel; +import org.keycloak.models.UserModel; +import org.keycloak.models.credential.dto.OTPSecretData; +import org.keycloak.util.JsonSerialization; + +import java.io.IOException; +import java.util.Optional; + +/** + * 证书使用 CredentialValidator 来认证,例如 password 证书 使用登录认证,本例中使用 phone OTP 认证 + * //not have credential , SmsOtpMfaAuthenticator setRequiredActions will add + * ConfigSmsOtpRequiredAction to add an OPT credential + * -> OTP + */ +public class PhoneOtpCredentialProvider implements CredentialProvider, CredentialInputValidator { + + private final static Logger logger = Logger.getLogger(PhoneOtpCredentialProvider.class); + private final KeycloakSession session; + + public PhoneOtpCredentialProvider(KeycloakSession session) { + this.session = session; + } + + private PhoneVerificationCodeProvider getTokenCodeService() { + return session.getProvider(PhoneVerificationCodeProvider.class); + } + + @Override + public boolean supportsCredentialType(String credentialType) { + return getType().equals(credentialType); + } + + @Override + public boolean isConfiguredFor(RealmModel realm, UserModel user, String credentialType) { + if (!supportsCredentialType(credentialType)) return false; + return user.credentialManager().getStoredCredentialsByTypeStream(credentialType).findAny().isPresent(); + } + + @Override + public boolean isValid(RealmModel realm, UserModel user, CredentialInput input) { + logger.info("---------------begin valid otp sms"); + + String phoneNumber = user.getFirstAttribute("phoneNumber"); + + + String code = input.getChallengeResponse(); + + if (!(input instanceof UserCredentialModel)) return false; + if (!input.getType().equals(getType())) return false; + if (phoneNumber == null) return false; + if (code == null) return false; + + + if (ObjectUtil.isBlank(input.getCredentialId())) { + logger.debugf("CredentialId is null when validating credential of user %s", user.getUsername()); + return false; + } + + CredentialModel credential = user.credentialManager().getStoredCredentialById(input.getCredentialId()); + var invalid = Optional.ofNullable(user.credentialManager().getStoredCredentialById(input.getCredentialId())) + .map(credentialModel -> { + try { + return JsonSerialization.readValue(credentialModel.getCredentialData(), PhoneOtpCredentialModel.SmsOtpCredentialData.class); + } catch (IOException e) { + throw new IllegalArgumentException(e); + } + }) + .map(PhoneOtpCredentialModel.SmsOtpCredentialData::isSecretInvalid) + .filter(invalidSecret -> invalidSecret) + .orElse(false); + if (invalid){ + try { + getTokenCodeService().validateCode(user, phoneNumber, code, TokenCodeType.OTP); + return true; + } catch (Exception e) { + return false; + } + } + return Optional.ofNullable(credential.getSecretData()) + .map(secretData -> { + try { + return JsonSerialization.readValue(secretData, OTPSecretData.class); + } catch (IOException e) { + throw new IllegalArgumentException(e); + } + }) + .flatMap(secretData -> Optional.ofNullable(secretData.getValue())) + .map(CredentialCode -> CredentialCode.equals(code)) + .orElse(false); + } + + @Override + public String getType() { + return PhoneOtpCredentialModel.TYPE; + } + + @Override + public CredentialModel createCredential(RealmModel realm, UserModel user, PhoneOtpCredentialModel credential) { + if (credential.getCreatedDate() == null) { + credential.setCreatedDate(Time.currentTimeMillis()); + } + return user.credentialManager().createStoredCredential(credential); + } + + @Override + public boolean deleteCredential(RealmModel realm, UserModel user, String credentialId) { + return user.credentialManager().removeStoredCredentialById(credentialId); +// return getCredentialStore().removeStoredCredential(realm, user, credentialId); + } + + @Override + public PhoneOtpCredentialModel getCredentialFromModel(CredentialModel credentialModel) { + return PhoneOtpCredentialModel.createFromCredentialModel(credentialModel); + } + + @Override + public CredentialTypeMetadata getCredentialTypeMetadata(CredentialTypeMetadataContext credentialTypeMetadataContext) { + return CredentialTypeMetadata.builder() + .type(getType()) + .helpText("") + .category(CredentialTypeMetadata.Category.TWO_FACTOR) + .displayName(PhoneOtpCredentialProviderFactory.PROVIDER_ID) + .createAction(SmsOtpMfaAuthenticatorFactory.PROVIDER_ID) + .removeable(true) + .build(session); + } +} diff --git a/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/credential/PhoneOtpCredentialProviderFactory.java b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/credential/PhoneOtpCredentialProviderFactory.java new file mode 100644 index 000000000..1166952f7 --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/credential/PhoneOtpCredentialProviderFactory.java @@ -0,0 +1,20 @@ +package cc.coopersoft.keycloak.phone.credential; + +import org.keycloak.credential.CredentialProvider; +import org.keycloak.credential.CredentialProviderFactory; +import org.keycloak.models.KeycloakSession; + +public class PhoneOtpCredentialProviderFactory implements CredentialProviderFactory { + + public final static String PROVIDER_ID = "sms-otp"; + + @Override + public CredentialProvider create(KeycloakSession session) { + return new PhoneOtpCredentialProvider(session); + } + + @Override + public String getId() { + return PROVIDER_ID; + } +} diff --git a/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/constants/TokenCodeType.java b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/constants/TokenCodeType.java new file mode 100644 index 000000000..d74ff9975 --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/constants/TokenCodeType.java @@ -0,0 +1,16 @@ +package cc.coopersoft.keycloak.phone.providers.constants; + +public enum TokenCodeType { + VERIFY("verification"), + AUTH("authentication"), + + OTP("OTP"), + RESET("reset credential"), + REGISTRATION("registration"); + + public final String label; + + TokenCodeType(String label) { + this.label = label; + } +} diff --git a/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/exception/MessageSendException.java b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/exception/MessageSendException.java new file mode 100644 index 000000000..94ab3acf0 --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/exception/MessageSendException.java @@ -0,0 +1,21 @@ +package cc.coopersoft.keycloak.phone.providers.exception; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +@EqualsAndHashCode(callSuper = true) +@Data +@NoArgsConstructor +@AllArgsConstructor +public class MessageSendException extends Exception { + + private Integer statusCode = -1; + private String errorCode = ""; + private String errorMessage = ""; + + public MessageSendException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/exception/PhoneNumberInvalidException.java b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/exception/PhoneNumberInvalidException.java new file mode 100644 index 000000000..2086159cd --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/exception/PhoneNumberInvalidException.java @@ -0,0 +1,43 @@ +package cc.coopersoft.keycloak.phone.providers.exception; + +import com.google.i18n.phonenumbers.NumberParseException; + +public class PhoneNumberInvalidException extends Exception{ + + public enum ErrorType { + + NOT_SUPPORTED("invalidPhoneNumberNotSupported"), + VALID_FAIL("invalidPhoneNumber"), + INVALID_COUNTRY_CODE("invalidPhoneNumberCountryCode"), + NOT_A_NUMBER("invalidPhoneNumberMustNumber"), + TOO_SHORT_AFTER_IDD("invalidPhoneNumberTooShort"), + TOO_SHORT_NSN("invalidPhoneNumberTooShort"), + TOO_LONG("invalidPhoneNumberTooLong"); + + private final String errorMessage; + + public String message(){ + return errorMessage; + } + + ErrorType(String message) { + this.errorMessage = message; + } + } + + private final ErrorType errorType; + + public PhoneNumberInvalidException(NumberParseException parseException) { + super(parseException); + this.errorType =ErrorType.valueOf(parseException.getErrorType().name()); + } + + public PhoneNumberInvalidException(ErrorType errorType,String message) { + super(message); + this.errorType = errorType; + } + + public ErrorType getErrorType() { + return errorType; + } +} diff --git a/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/jpa/TokenCode.java b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/jpa/TokenCode.java new file mode 100644 index 000000000..2a70a9440 --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/jpa/TokenCode.java @@ -0,0 +1,71 @@ +package cc.coopersoft.keycloak.phone.providers.jpa; + +import jakarta.persistence.*; +import lombok.Data; + +import java.util.Date; + +@Entity +@Data +@Table(name = "PHONE_MESSAGE_TOKEN_CODE") +@NamedQueries({ + @NamedQuery( + name = "ongoingProcess", + query = "FROM TokenCode t WHERE t.realmId = :realmId " + + "AND t.phoneNumber = :phoneNumber " + + "AND t.expiresAt >= :now AND t.type = :type" + ), + @NamedQuery( + name = "processesSinceTarget", + query = "SELECT COUNT(t) FROM TokenCode t WHERE t.realmId = :realmId " + + "AND t.phoneNumber = :phoneNumber " + + "AND t.createdAt >= :date AND t.type = :type" + ), + @NamedQuery( + name = "processesSinceSource", + query = "SELECT COUNT(t) FROM TokenCode t WHERE t.realmId = :realmId " + + "AND t.ip = :addr " + + "AND t.createdAt >= :date AND t.type = :type" + ) +}) +public class TokenCode { + + @Id + @Column(name = "ID") + private String id; + + @Column(name = "REALM_ID", nullable = false) + private String realmId; + + @Column(name = "PHONE_NUMBER", nullable = false) + private String phoneNumber; + + @Column(name = "TYPE", nullable = false) + private String type; + + @Column(name = "CODE", nullable = false) + private String code; + + @Temporal(TemporalType.TIMESTAMP) + @Column(name = "CREATED_AT", nullable = false) + private Date createdAt; + + @Temporal(TemporalType.TIMESTAMP) + @Column(name = "EXPIRES_AT", nullable = false) + private Date expiresAt; + + @Column(name = "CONFIRMED", nullable = false) + private Boolean confirmed; + + @Column(name = "BY_WHOM", nullable = true) + private String byWhom; + + @Column(name = "IP") + private String ip; + + @Column(name = "PORT") + private Integer port; + + @Column(name = "HOST") + private String host; +} diff --git a/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/jpa/TokenCodeJpaEntityProvider.java b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/jpa/TokenCodeJpaEntityProvider.java new file mode 100644 index 000000000..87594ed2e --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/jpa/TokenCodeJpaEntityProvider.java @@ -0,0 +1,28 @@ +package cc.coopersoft.keycloak.phone.providers.jpa; + +import org.keycloak.connections.jpa.entityprovider.JpaEntityProvider; + +import java.util.Collections; +import java.util.List; + +public class TokenCodeJpaEntityProvider implements JpaEntityProvider { + + @Override + public List> getEntities() { + return Collections.singletonList(TokenCode.class); + } + + @Override + public String getChangelogLocation() { + return "META-INF/changelog/token-code-changelog.xml"; + } + + @Override + public void close() { + } + + @Override + public String getFactoryId() { + return "tokenCodeEntityProvider"; + } +} \ No newline at end of file diff --git a/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/jpa/TokenCodeJpaEntityProviderFactory.java b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/jpa/TokenCodeJpaEntityProviderFactory.java new file mode 100644 index 000000000..37a38464b --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/jpa/TokenCodeJpaEntityProviderFactory.java @@ -0,0 +1,32 @@ +package cc.coopersoft.keycloak.phone.providers.jpa; + +import org.keycloak.Config; +import org.keycloak.connections.jpa.entityprovider.JpaEntityProvider; +import org.keycloak.connections.jpa.entityprovider.JpaEntityProviderFactory; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; + +public class TokenCodeJpaEntityProviderFactory implements JpaEntityProviderFactory { + + @Override + public JpaEntityProvider create(KeycloakSession session) { + return new TokenCodeJpaEntityProvider(); + } + + @Override + public String getId() { + return "tokenCodeEntityProvider"; + } + + @Override + public void init(Config.Scope config) { + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + } + + @Override + public void close() { + } +} diff --git a/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/representations/TokenCodeRepresentation.java b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/representations/TokenCodeRepresentation.java new file mode 100644 index 000000000..a8cff45e6 --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/representations/TokenCodeRepresentation.java @@ -0,0 +1,106 @@ +package cc.coopersoft.keycloak.phone.providers.representations; + +import java.security.SecureRandom; +import java.util.Date; + +import org.keycloak.models.utils.KeycloakModelUtils; + +public class TokenCodeRepresentation { + + private String id; + private String phoneNumber; + private String code; + private String type; + private Date createdAt; + private Date expiresAt; + private Boolean confirmed; + + // Default constructor + public TokenCodeRepresentation() { + } + + // All-args constructor + public TokenCodeRepresentation(String id, String phoneNumber, String code, String type, Date createdAt, Date expiresAt, Boolean confirmed) { + this.id = id; + this.phoneNumber = phoneNumber; + this.code = code; + this.type = type; + this.createdAt = createdAt; + this.expiresAt = expiresAt; + this.confirmed = confirmed; + } + + // Getters and Setters for all fields + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getPhoneNumber() { + return phoneNumber; + } + + public void setPhoneNumber(String phoneNumber) { + this.phoneNumber = phoneNumber; + } + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public Date getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Date createdAt) { + this.createdAt = createdAt; + } + + public Date getExpiresAt() { + return expiresAt; + } + + public void setExpiresAt(Date expiresAt) { + this.expiresAt = expiresAt; + } + + public Boolean getConfirmed() { + return confirmed; + } + + public void setConfirmed(Boolean confirmed) { + this.confirmed = confirmed; + } + + // Static method to generate TokenCodeRepresentation for phone number + public static TokenCodeRepresentation forPhoneNumber(String phoneNumber) { + TokenCodeRepresentation tokenCode = new TokenCodeRepresentation(); + tokenCode.id = KeycloakModelUtils.generateId(); + tokenCode.phoneNumber = phoneNumber; + tokenCode.code = generateTokenCode(); + tokenCode.confirmed = false; + return tokenCode; + } + + // Method to generate token code + private static String generateTokenCode() { + SecureRandom secureRandom = new SecureRandom(); + Integer code = secureRandom.nextInt(999_999); + return String.format("%06d", code); + } +} diff --git a/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/rest/SmsResource.java b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/rest/SmsResource.java new file mode 100644 index 000000000..950c7d47d --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/rest/SmsResource.java @@ -0,0 +1,40 @@ +package cc.coopersoft.keycloak.phone.providers.rest; + +import cc.coopersoft.keycloak.phone.providers.constants.TokenCodeType; +import org.keycloak.models.KeycloakSession; +import jakarta.ws.rs.*; + +public class SmsResource { + + private final KeycloakSession session; + + public SmsResource(KeycloakSession session) { + this.session = session; + } + + @Path("verification-code") + public VerificationCodeResource getVerificationCodeResource() { + return new VerificationCodeResource(session); + } + + @Path("authentication-code") + public TokenCodeResource getAuthenticationCodeResource() { + return new TokenCodeResource(session, TokenCodeType.AUTH); + } + + @Path("registration-code") + public TokenCodeResource getRegistrationCodeResource() { + return new TokenCodeResource(session, TokenCodeType.REGISTRATION); + } + + @Path("reset-code") + public TokenCodeResource getResetCodeResource() { + return new TokenCodeResource(session, TokenCodeType.RESET); + } + + @Path("otp-code") + public TokenCodeResource getOTPCodeResource() { + return new TokenCodeResource(session, TokenCodeType.OTP); + } + +} diff --git a/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/rest/SmsResourceProvider.java b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/rest/SmsResourceProvider.java new file mode 100644 index 000000000..a6d58382a --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/rest/SmsResourceProvider.java @@ -0,0 +1,22 @@ +package cc.coopersoft.keycloak.phone.providers.rest; + +import org.keycloak.models.KeycloakSession; +import org.keycloak.services.resource.RealmResourceProvider; + +public class SmsResourceProvider implements RealmResourceProvider { + + private final KeycloakSession session; + + SmsResourceProvider(KeycloakSession session) { + this.session = session; + } + + @Override + public Object getResource() { + return new SmsResource(session); + } + + @Override + public void close() { + } +} \ No newline at end of file diff --git a/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/rest/SmsResourceProviderFactory.java b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/rest/SmsResourceProviderFactory.java new file mode 100644 index 000000000..7e870da67 --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/rest/SmsResourceProviderFactory.java @@ -0,0 +1,37 @@ +package cc.coopersoft.keycloak.phone.providers.rest; + +import org.jboss.logging.Logger; +import org.keycloak.Config.Scope; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.services.resource.RealmResourceProvider; +import org.keycloak.services.resource.RealmResourceProviderFactory; + + +public class SmsResourceProviderFactory implements RealmResourceProviderFactory { + + private static final Logger logger = Logger.getLogger(SmsResourceProviderFactory.class); + + @Override + public String getId() { + return "sms"; + } + + @Override + public RealmResourceProvider create(KeycloakSession session) { + return new SmsResourceProvider(session); + } + + @Override + public void init(Scope config) { + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + } + + @Override + public void close() { + } + +} \ No newline at end of file diff --git a/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/rest/TokenCodeResource.java b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/rest/TokenCodeResource.java new file mode 100644 index 000000000..6e51662af --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/rest/TokenCodeResource.java @@ -0,0 +1,74 @@ +package cc.coopersoft.keycloak.phone.providers.rest; + +import org.jboss.logging.Logger; +import org.jboss.resteasy.annotations.cache.NoCache; +import org.keycloak.models.KeycloakSession; +import org.keycloak.services.validation.Validation; + +import cc.coopersoft.keycloak.phone.Utils; +import cc.coopersoft.keycloak.phone.providers.constants.TokenCodeType; +import cc.coopersoft.keycloak.phone.providers.exception.PhoneNumberInvalidException; +import cc.coopersoft.keycloak.phone.providers.spi.PhoneProvider; +import jakarta.validation.constraints.NotBlank; +import jakarta.ws.rs.BadRequestException; +import jakarta.ws.rs.ForbiddenException; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import static jakarta.ws.rs.core.MediaType.APPLICATION_JSON; +import static jakarta.ws.rs.core.MediaType.APPLICATION_JSON_TYPE; +import jakarta.ws.rs.core.Response; + +public class TokenCodeResource { + + private static final Logger logger = Logger.getLogger(TokenCodeResource.class); + protected final KeycloakSession session; + protected final TokenCodeType tokenCodeType; + + TokenCodeResource(KeycloakSession session, TokenCodeType tokenCodeType) { + this.session = session; + this.tokenCodeType = tokenCodeType; + } + + @GET + @NoCache + @Path("") + @Produces(APPLICATION_JSON) + public Response getTokenCode(@NotBlank @QueryParam("phoneNumber") String phoneNumber, + @QueryParam("kind") String kind) { + + if (Validation.isBlank(phoneNumber)) { + throw new BadRequestException("Must supply a phone number"); + } + + // ensure phone number starts with + + if (!phoneNumber.startsWith("+")) { + phoneNumber = "+" + phoneNumber; + } + + var phoneProvider = session.getProvider(PhoneProvider.class); + + try { + phoneNumber = Utils.canonicalizePhoneNumber(session, phoneNumber); + } catch (PhoneNumberInvalidException e) { + throw new BadRequestException("Phone number is invalid"); + } + + // everybody phones authenticator send AUTH code + if (!TokenCodeType.REGISTRATION.equals(tokenCodeType) + && !TokenCodeType.AUTH.equals(tokenCodeType) + && !TokenCodeType.VERIFY.equals(tokenCodeType) + && Utils.findUserByPhone(session, session.getContext().getRealm(), phoneNumber).isEmpty()) { + throw new ForbiddenException("Phone number not found"); + } + + logger.info(String.format("Requested %s code to %s", tokenCodeType.label, phoneNumber)); + int tokenExpiresIn = phoneProvider.sendTokenCode(phoneNumber, + session.getContext().getConnection().getRemoteAddr(), tokenCodeType, kind); + + String response = String.format("{\"expires_in\":%s}", tokenExpiresIn); + + return Response.ok(response, APPLICATION_JSON_TYPE).build(); + } +} diff --git a/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/rest/VerificationCodeResource.java b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/rest/VerificationCodeResource.java new file mode 100644 index 000000000..216e6f29b --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/rest/VerificationCodeResource.java @@ -0,0 +1,47 @@ +package cc.coopersoft.keycloak.phone.providers.rest; + +import cc.coopersoft.keycloak.phone.providers.constants.TokenCodeType; +import cc.coopersoft.keycloak.phone.providers.spi.PhoneVerificationCodeProvider; +import jakarta.ws.rs.*; +import org.jboss.logging.Logger; +import org.jboss.resteasy.annotations.cache.NoCache; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.UserModel; +import org.keycloak.services.managers.AppAuthManager; +import org.keycloak.services.managers.AuthenticationManager.AuthResult; +import jakarta.ws.rs.core.Response; + +import static jakarta.ws.rs.core.MediaType.APPLICATION_JSON; + +public class VerificationCodeResource extends TokenCodeResource { + + private static final Logger logger = Logger.getLogger(VerificationCodeResource.class); + + private final AuthResult auth; + + VerificationCodeResource(KeycloakSession session) { + super(session, TokenCodeType.VERIFY); + this.auth = new AppAuthManager.BearerTokenAuthenticator(session).authenticate(); + } + + private PhoneVerificationCodeProvider getTokenCodeService() { + return session.getProvider(PhoneVerificationCodeProvider.class); + } + + @POST + @NoCache + @Path("") + @Produces(APPLICATION_JSON) + public Response checkVerificationCode(@QueryParam("phoneNumber") String phoneNumber, + @QueryParam("code") String code) { + + if (auth == null) throw new NotAuthorizedException("Bearer"); + if (phoneNumber == null) throw new BadRequestException("Must inform a phone number"); + if (code == null) throw new BadRequestException("Must inform a token code"); + + UserModel user = auth.getUser(); + getTokenCodeService().validateCode(user, phoneNumber, code); + + return Response.noContent().build(); + } +} diff --git a/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/spi/FullSmsSenderAbstractService.java b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/spi/FullSmsSenderAbstractService.java new file mode 100644 index 000000000..d9fc514e8 --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/spi/FullSmsSenderAbstractService.java @@ -0,0 +1,92 @@ +package cc.coopersoft.keycloak.phone.providers.spi; + +import cc.coopersoft.keycloak.phone.Utils; +import cc.coopersoft.keycloak.phone.providers.constants.TokenCodeType; +import cc.coopersoft.keycloak.phone.providers.exception.MessageSendException; + +import java.text.MessageFormat; +import java.util.Locale; +import java.util.Optional; +import java.util.Properties; + +import org.jboss.logging.Logger; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.UserModel; +import org.keycloak.theme.Theme; + +public abstract class FullSmsSenderAbstractService implements MessageSenderService { + private static final Logger logger = Logger.getLogger(FullSmsSenderAbstractService.class); + + private final String realmDisplay; + + private final KeycloakSession session; + + @Deprecated + public FullSmsSenderAbstractService(String realmDisplay) { + this.realmDisplay = realmDisplay; + this.session = null; + } + + public FullSmsSenderAbstractService(KeycloakSession session) { + this.session = session; + this.realmDisplay = session.getContext().getRealm().getDisplayName(); + } + + public abstract void sendMessage(String phoneNumber, String message) throws MessageSendException; + + @Override + public void sendSmsMessage(TokenCodeType type, String phoneNumber, String code, int expires, String kind) + throws MessageSendException { + final String defaultMessage = String.format("[%s] - " + type.label + " code: %s, expires: %s minute ", + realmDisplay, code, expires / 60); + final String MESSAGE = localizeMessage(type, phoneNumber, code, expires).orElse(defaultMessage); + sendMessage(phoneNumber, MESSAGE); + } + + /** + * Localizes sms code message template from login theme. + * + * @param type the type of code sent + * @param phoneNumber the user's phone number (if applicable) + * @param code the verification code + * @param expires code expiration in seconds + * @return The localized string, else empty. + */ + private Optional localizeMessage(TokenCodeType type, String phoneNumber, String code, int expires) { + if (this.session != null) { + try { + // Get login theme + final String loginThemeName = session.getContext().getRealm().getLoginTheme(); + final Theme loginTheme = session.theme().getTheme(loginThemeName, Theme.Type.LOGIN); + + // Try get locale from user associated with phone number (if any) + final Optional user = Utils.findUserByPhone(session, session.getContext().getRealm(), + phoneNumber); + final Optional userLocale = user.map(u -> u.getFirstAttribute(UserModel.LOCALE)); + + // Use locale from user or default to realm locale + final String localeName = userLocale.isPresent() ? userLocale.get() + : session.getContext().getRealm().getDefaultLocale(); + final Locale locale = Locale.forLanguageTag(localeName); + + // Get message template from login theme bundle + final Properties messages = loginTheme.getMessages(locale); + final String messageTemplate = messages.getProperty("smsCodeMessage"); + + // Return nothing when template can't be found + if (messageTemplate == null || messageTemplate.isBlank()) { + return Optional.empty(); + } + + // Format message + MessageFormat mf = new MessageFormat(messageTemplate, locale); + return Optional.of(mf.format(new Object[] { realmDisplay, type.label, code, expires / 60 })); + } catch (Exception ex) { + logger.error("Error while trying to localize message", ex); + return Optional.empty(); + } + } + + return Optional.empty(); + } +} diff --git a/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/spi/MessageSenderService.java b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/spi/MessageSenderService.java new file mode 100644 index 000000000..58d752e07 --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/spi/MessageSenderService.java @@ -0,0 +1,17 @@ +package cc.coopersoft.keycloak.phone.providers.spi; + +import cc.coopersoft.keycloak.phone.providers.constants.TokenCodeType; +import cc.coopersoft.keycloak.phone.providers.exception.MessageSendException; +import org.keycloak.provider.Provider; + + +/** + * SMS, Voice, APP + */ +public interface MessageSenderService extends Provider { + + //void sendVoiceMessage((TokenCodeType type, String realmName, String realmDisplayName, String phoneNumber, String code , int expires) throws MessageSendException; + + + void sendSmsMessage(TokenCodeType type, String phoneNumber, String code , int expires , String kind) throws MessageSendException; +} diff --git a/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/spi/MessageSenderServiceProviderFactory.java b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/spi/MessageSenderServiceProviderFactory.java new file mode 100644 index 000000000..e85af30d0 --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/spi/MessageSenderServiceProviderFactory.java @@ -0,0 +1,6 @@ +package cc.coopersoft.keycloak.phone.providers.spi; + +import org.keycloak.provider.ProviderFactory; + +public interface MessageSenderServiceProviderFactory extends ProviderFactory { +} diff --git a/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/spi/MessageSenderServiceSpi.java b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/spi/MessageSenderServiceSpi.java new file mode 100644 index 000000000..52cb71c8a --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/spi/MessageSenderServiceSpi.java @@ -0,0 +1,29 @@ +package cc.coopersoft.keycloak.phone.providers.spi; + +import org.keycloak.provider.Provider; +import org.keycloak.provider.ProviderFactory; +import org.keycloak.provider.Spi; + +public class MessageSenderServiceSpi implements Spi { + + @Override + public boolean isInternal() { + return false; + } + + @Override + public String getName() { + return "messageSenderService"; + } + + @Override + public Class getProviderClass() { + return MessageSenderService.class; + } + + @Override + @SuppressWarnings("rawtypes") + public Class getProviderFactoryClass() { + return MessageSenderServiceProviderFactory.class; + } +} \ No newline at end of file diff --git a/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/spi/PhoneProvider.java b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/spi/PhoneProvider.java new file mode 100644 index 000000000..29c0e2552 --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/spi/PhoneProvider.java @@ -0,0 +1,32 @@ +package cc.coopersoft.keycloak.phone.providers.spi; + +import cc.coopersoft.keycloak.phone.providers.constants.TokenCodeType; +import org.keycloak.provider.Provider; + +import java.util.Optional; + + +public interface PhoneProvider extends Provider { + + //TODO on key login support + //boolean Verification(String phoneNumber, String token); + + boolean isDuplicatePhoneAllowed(); + + boolean validPhoneNumber(); + + boolean compatibleMode(); + + int otpExpires(); + + Optional canonicalizePhoneNumber(); + + Optional defaultPhoneRegion(); + + Optional phoneNumberRegex(); + + int sendTokenCode(String phoneNumber, String sourceAddr, TokenCodeType type, String kind); + + + +} diff --git a/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/spi/PhoneProviderFactory.java b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/spi/PhoneProviderFactory.java new file mode 100644 index 000000000..4489266e6 --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/spi/PhoneProviderFactory.java @@ -0,0 +1,6 @@ +package cc.coopersoft.keycloak.phone.providers.spi; + +import org.keycloak.provider.ProviderFactory; + +public interface PhoneProviderFactory extends ProviderFactory { +} diff --git a/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/spi/PhoneSpi.java b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/spi/PhoneSpi.java new file mode 100644 index 000000000..1b8153f9b --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/spi/PhoneSpi.java @@ -0,0 +1,28 @@ +package cc.coopersoft.keycloak.phone.providers.spi; + +import org.keycloak.provider.Provider; +import org.keycloak.provider.ProviderFactory; +import org.keycloak.provider.Spi; + +public class PhoneSpi implements Spi { + + @Override + public boolean isInternal() { + return false; + } + + @Override + public String getName() { + return "phone"; + } + + @Override + public Class getProviderClass() { + return PhoneProvider.class; + } + + @Override + public Class getProviderFactoryClass() { + return PhoneProviderFactory.class; + } +} diff --git a/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/spi/PhoneVerificationCodeProvider.java b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/spi/PhoneVerificationCodeProvider.java new file mode 100644 index 000000000..f79d325b2 --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/spi/PhoneVerificationCodeProvider.java @@ -0,0 +1,25 @@ +package cc.coopersoft.keycloak.phone.providers.spi; + +import cc.coopersoft.keycloak.phone.providers.constants.TokenCodeType; +import cc.coopersoft.keycloak.phone.providers.representations.TokenCodeRepresentation; +import org.keycloak.models.UserModel; +import org.keycloak.provider.Provider; + +public interface PhoneVerificationCodeProvider extends Provider { + + TokenCodeRepresentation ongoingProcess(String phoneNumber, TokenCodeType tokenCodeType); + + boolean isAbusing(String phoneNumber, TokenCodeType tokenCodeType,String sourceAddr ,int sourceHourMaximum,int targetHourMaximum); + + void persistCode(TokenCodeRepresentation tokenCode, TokenCodeType tokenCodeType, int tokenExpiresIn); + + void validateCode(UserModel user, String phoneNumber, String code); + + void validateCode(UserModel user, String phoneNumber, String code, TokenCodeType tokenCodeType); + + void validateProcess(String tokenCodeId, UserModel user); + + //void cleanUpAction(UserModel user, boolean isOTP); + + void tokenValidated(UserModel user, String phoneNumber, String tokenCodeId, boolean isOTP); +} diff --git a/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/spi/PhoneVerificationCodeProviderFactory.java b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/spi/PhoneVerificationCodeProviderFactory.java new file mode 100644 index 000000000..769b304d9 --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/spi/PhoneVerificationCodeProviderFactory.java @@ -0,0 +1,6 @@ +package cc.coopersoft.keycloak.phone.providers.spi; + +import org.keycloak.provider.ProviderFactory; + +public interface PhoneVerificationCodeProviderFactory extends ProviderFactory { +} diff --git a/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/spi/PhoneVerificationCodeSpi.java b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/spi/PhoneVerificationCodeSpi.java new file mode 100644 index 000000000..a8feeac1c --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/spi/PhoneVerificationCodeSpi.java @@ -0,0 +1,29 @@ +package cc.coopersoft.keycloak.phone.providers.spi; + +import org.keycloak.provider.Provider; +import org.keycloak.provider.ProviderFactory; +import org.keycloak.provider.Spi; + +public class PhoneVerificationCodeSpi implements Spi { + + @Override + public boolean isInternal() { + return false; + } + + @Override + public String getName() { + return "phoneVerificationCode"; + } + + @Override + public Class getProviderClass() { + return PhoneVerificationCodeProvider.class; + } + + @Override + @SuppressWarnings("rawtypes") + public Class getProviderFactoryClass() { + return PhoneVerificationCodeProviderFactory.class; + } +} \ No newline at end of file diff --git a/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/spi/impl/DefaultPhoneProvider.java b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/spi/impl/DefaultPhoneProvider.java new file mode 100644 index 000000000..917bb5c85 --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/spi/impl/DefaultPhoneProvider.java @@ -0,0 +1,148 @@ +package cc.coopersoft.keycloak.phone.providers.spi.impl; + +import java.time.Instant; +import java.util.Optional; + +import org.jboss.logging.Logger; +import org.keycloak.Config.Scope; +import org.keycloak.models.KeycloakSession; +import org.keycloak.services.validation.Validation; + +import cc.coopersoft.common.OptionalUtils; +import cc.coopersoft.keycloak.phone.providers.constants.TokenCodeType; +import cc.coopersoft.keycloak.phone.providers.exception.MessageSendException; +import cc.coopersoft.keycloak.phone.providers.representations.TokenCodeRepresentation; +import cc.coopersoft.keycloak.phone.providers.spi.MessageSenderService; +import cc.coopersoft.keycloak.phone.providers.spi.PhoneProvider; +import cc.coopersoft.keycloak.phone.providers.spi.PhoneVerificationCodeProvider; +import jakarta.ws.rs.ForbiddenException; +import jakarta.ws.rs.ServiceUnavailableException; + +public class DefaultPhoneProvider implements PhoneProvider { + + private static final Logger logger = Logger.getLogger(DefaultPhoneProvider.class); + private final KeycloakSession session; + private final String service; + private final int tokenExpiresIn; + private final int targetHourMaximum; + private final int sourceHourMaximum; + + private final Scope config; + + DefaultPhoneProvider(KeycloakSession session, Scope config) { + this.session = session; + this.config = config; + + this.service = session.listProviderIds(MessageSenderService.class) + .stream().filter(s -> s.equals(config.get("service"))) + .findFirst().orElse( + session.listProviderIds(MessageSenderService.class) + .stream().findFirst().orElse(null) + ); + + if (Validation.isBlank(this.service)) { + logger.error("Message sender service provider not found!"); + } + + if (Validation.isBlank(config.get("service"))) { + logger.warn("No message sender service provider specified! Default provider'" + + this.service + "' will be used. You can use keycloak start param '--spi-phone-default-service' to specify a different one. "); + } + + this.tokenExpiresIn = config.getInt("tokenExpiresIn", 60); + this.targetHourMaximum = config.getInt("targetHourMaximum", 3); + this.sourceHourMaximum = config.getInt("sourceHourMaximum", 10); + } + + @Override + public void close() { + } + + private PhoneVerificationCodeProvider getTokenCodeService() { + return session.getProvider(PhoneVerificationCodeProvider.class); + } + + private String getRealmName() { + return session.getContext().getRealm().getName(); + } + + private Optional getStringConfigValue(String configName) { + return OptionalUtils.ofBlank(OptionalUtils.ofBlank(config.get(getRealmName() + "-" + configName)) + .orElse(config.get(configName))); + } + + private boolean getBooleanConfigValue(String configName, boolean defaultValue) { + Boolean result = config.getBoolean(getRealmName() + "-" + configName, null); + if (result == null) { + result = config.getBoolean(configName, defaultValue); + } + return result; + } + + @Override + public boolean isDuplicatePhoneAllowed() { + return getBooleanConfigValue("duplicate-phone", false); + } + + @Override + public boolean validPhoneNumber() { + return getBooleanConfigValue("valid-phone", true); + } + + @Override + public boolean compatibleMode() { + return getBooleanConfigValue("compatible", false); + } + + @Override + public int otpExpires() { + return getStringConfigValue("otp-expires").map(Integer::valueOf).orElse(60 * 60); + } + + @Override + public Optional canonicalizePhoneNumber() { + return getStringConfigValue("canonicalize-phone-numbers"); + } + + @Override + public Optional defaultPhoneRegion() { + return getStringConfigValue("phone-default-region"); + } + + @Override + public Optional phoneNumberRegex() { + return getStringConfigValue("number-regex"); + } + + @Override + public int sendTokenCode(String phoneNumber, String sourceAddr, TokenCodeType type, String kind) { + + logger.info("send code to:" + phoneNumber); + + if (getTokenCodeService().isAbusing(phoneNumber, type, sourceAddr, sourceHourMaximum, targetHourMaximum)) { + throw new ForbiddenException("You requested the maximum number of messages the last hour"); + } + + TokenCodeRepresentation ongoing = getTokenCodeService().ongoingProcess(phoneNumber, type); + if (ongoing != null) { + logger.info(String.format("No need of sending a new %s code for %s", type.label, phoneNumber)); + return (int) (ongoing.getExpiresAt().getTime() - Instant.now().toEpochMilli()) / 1000; + } + + TokenCodeRepresentation token = TokenCodeRepresentation.forPhoneNumber(phoneNumber); + + try { + session.getProvider(MessageSenderService.class, service).sendSmsMessage(type, phoneNumber, token.getCode(), tokenExpiresIn, kind); + getTokenCodeService().persistCode(token, type, tokenExpiresIn); + + logger.info(String.format("Sent %s code to %s over %s", type.label, phoneNumber, service)); + + } catch (MessageSendException e) { + logger.error(String.format("Message sending to %s failed with %s:", phoneNumber, e.toString())); + throw new ServiceUnavailableException("Internal server error"); + } + + return tokenExpiresIn; + } + +} diff --git a/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/spi/impl/DefaultPhoneProviderFactory.java b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/spi/impl/DefaultPhoneProviderFactory.java new file mode 100644 index 000000000..635651696 --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/spi/impl/DefaultPhoneProviderFactory.java @@ -0,0 +1,35 @@ +package cc.coopersoft.keycloak.phone.providers.spi.impl; + +import cc.coopersoft.keycloak.phone.providers.spi.PhoneProvider; +import cc.coopersoft.keycloak.phone.providers.spi.PhoneProviderFactory; +import org.keycloak.Config.Scope; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; + +public class DefaultPhoneProviderFactory implements PhoneProviderFactory { + + private Scope config; + + @Override + public PhoneProvider create(KeycloakSession session) { + return new DefaultPhoneProvider(session, config); + } + + @Override + public void init(Scope config) { + this.config = config; + } + + @Override + public void postInit(KeycloakSessionFactory keycloakSessionFactory) { + } + + @Override + public void close() { + } + + @Override + public String getId() { + return "default"; + } +} diff --git a/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/spi/impl/DefaultPhoneVerificationCodeProvider.java b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/spi/impl/DefaultPhoneVerificationCodeProvider.java new file mode 100644 index 000000000..357864595 --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/spi/impl/DefaultPhoneVerificationCodeProvider.java @@ -0,0 +1,241 @@ +package cc.coopersoft.keycloak.phone.providers.spi.impl; + +import cc.coopersoft.keycloak.phone.Utils; +import cc.coopersoft.keycloak.phone.authentication.requiredactions.ConfigSmsOtpRequiredAction; +import cc.coopersoft.keycloak.phone.authentication.requiredactions.UpdatePhoneNumberRequiredAction; +import cc.coopersoft.keycloak.phone.credential.PhoneOtpCredentialModel; +import cc.coopersoft.keycloak.phone.credential.PhoneOtpCredentialProvider; +import cc.coopersoft.keycloak.phone.credential.PhoneOtpCredentialProviderFactory; +import cc.coopersoft.keycloak.phone.providers.constants.TokenCodeType; +import cc.coopersoft.keycloak.phone.providers.jpa.TokenCode; +import cc.coopersoft.keycloak.phone.providers.representations.TokenCodeRepresentation; +import cc.coopersoft.keycloak.phone.providers.spi.PhoneVerificationCodeProvider; +import jakarta.persistence.EntityManager; +import jakarta.persistence.NoResultException; +import jakarta.persistence.TemporalType; +import jakarta.ws.rs.BadRequestException; +import jakarta.ws.rs.ForbiddenException; +import org.jboss.logging.Logger; +import org.keycloak.connections.jpa.JpaConnectionProvider; +import org.keycloak.credential.CredentialModel; +import org.keycloak.credential.CredentialProvider; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.services.validation.Validation; +import org.keycloak.util.JsonSerialization; + +import java.io.IOException; +import java.time.Instant; +import java.util.Date; +import java.util.concurrent.TimeUnit; + +public class DefaultPhoneVerificationCodeProvider implements PhoneVerificationCodeProvider { + + private static final Logger logger = Logger.getLogger(DefaultPhoneVerificationCodeProvider.class); + private final KeycloakSession session; + + DefaultPhoneVerificationCodeProvider(KeycloakSession session) { + this.session = session; + if (getRealm() == null) { + throw new IllegalStateException("The service cannot accept a session without a realm in its context."); + } + } + + private EntityManager getEntityManager() { + return session.getProvider(JpaConnectionProvider.class).getEntityManager(); + } + + private RealmModel getRealm() { + return session.getContext().getRealm(); + } + + @Override + public TokenCodeRepresentation ongoingProcess(String phoneNumber, TokenCodeType tokenCodeType) { + + try { + TokenCode entity = getEntityManager() + .createNamedQuery("ongoingProcess", TokenCode.class) + .setParameter("realmId", getRealm().getId()) + .setParameter("phoneNumber", phoneNumber) + .setParameter("now", new Date(), TemporalType.TIMESTAMP) + .setParameter("type", tokenCodeType.name()) + .getSingleResult(); + + TokenCodeRepresentation tokenCodeRepresentation = new TokenCodeRepresentation(); + + tokenCodeRepresentation.setId(entity.getId()); + tokenCodeRepresentation.setPhoneNumber(entity.getPhoneNumber()); + tokenCodeRepresentation.setCode(entity.getCode()); + tokenCodeRepresentation.setType(entity.getType()); + tokenCodeRepresentation.setCreatedAt(entity.getCreatedAt()); + tokenCodeRepresentation.setExpiresAt(entity.getExpiresAt()); + tokenCodeRepresentation.setConfirmed(entity.getConfirmed()); + + return tokenCodeRepresentation; + } catch (NoResultException e) { + return null; + } + } + + @Override + public boolean isAbusing(String phoneNumber, TokenCodeType tokenCodeType, + String sourceAddr, int sourceHourMaximum, int targetHourMaximum) { + + Date oneHourAgo = new Date(System.currentTimeMillis() - TimeUnit.HOURS.toMillis(1)); + + if (targetHourMaximum > 0) { + long targetCount = (getEntityManager() + .createNamedQuery("processesSinceTarget", Long.class) + .setParameter("realmId", getRealm().getId()) + .setParameter("phoneNumber", phoneNumber) + .setParameter("date", oneHourAgo, TemporalType.TIMESTAMP) + .setParameter("type", tokenCodeType.name()) + .getSingleResult()); + if (targetCount > targetHourMaximum) { + return true; + } + } + + if (sourceHourMaximum > 0) { + long sourceCount = (getEntityManager() + .createNamedQuery("processesSinceSource", Long.class) + .setParameter("realmId", getRealm().getId()) + .setParameter("addr", sourceAddr) + .setParameter("date", oneHourAgo, TemporalType.TIMESTAMP) + .setParameter("type", tokenCodeType.name()) + .getSingleResult()); + if (sourceCount > sourceHourMaximum) { + return true; + } + } + + return false; + } + + @Override + public void persistCode(TokenCodeRepresentation tokenCode, TokenCodeType tokenCodeType, int tokenExpiresIn) { + + TokenCode entity = new TokenCode(); + Instant now = Instant.now(); + + entity.setId(tokenCode.getId()); + entity.setRealmId(getRealm().getId()); + entity.setPhoneNumber(tokenCode.getPhoneNumber()); + entity.setCode(tokenCode.getCode()); + entity.setType(tokenCodeType.name()); + entity.setCreatedAt(Date.from(now)); + entity.setExpiresAt(Date.from(now.plusSeconds(tokenExpiresIn))); + entity.setConfirmed(tokenCode.getConfirmed()); + if (session.getContext().getConnection() != null) { + entity.setIp(session.getContext().getConnection().getRemoteAddr()); + entity.setPort(session.getContext().getConnection().getRemotePort()); + entity.setHost(session.getContext().getConnection().getRemoteHost()); + } + + getEntityManager().persist(entity); + } + + @Override + public void validateCode(UserModel user, String phoneNumber, String code) { + validateCode(user, phoneNumber, code, TokenCodeType.VERIFY); + } + + @Override + public void validateCode(UserModel user, String phoneNumber, String code, TokenCodeType tokenCodeType) { + + logger.info(String.format("valid %s , phone: %s, code: %s", tokenCodeType, phoneNumber, code)); + + TokenCodeRepresentation tokenCode = ongoingProcess(phoneNumber, tokenCodeType); + if (tokenCode == null) { + throw new BadRequestException(String.format("There is no valid ongoing %s process", tokenCodeType.label)); + } + + if (!tokenCode.getCode().equals(code)) { + throw new ForbiddenException("Code does not match with expected value"); + } + + logger.info(String.format("User %s correctly answered the %s code", user.getId(), tokenCodeType.label)); + + tokenValidated(user, phoneNumber, tokenCode.getId(), TokenCodeType.OTP.equals(tokenCodeType)); + + if (TokenCodeType.OTP.equals(tokenCodeType)) { + updateUserOTPCredential(user, phoneNumber, tokenCode.getCode()); + } + } + + @Override + public void tokenValidated(UserModel user, String phoneNumber, String tokenCodeId, boolean isOTP) { + + boolean updateUserPhoneNumber = !isOTP; + if (isOTP) { + updateUserPhoneNumber = PhoneOtpCredentialModel.getSmsOtpCredentialData(user) + .map(PhoneOtpCredentialModel.SmsOtpCredentialData::getPhoneNumber) + .map(pn -> pn.equals(phoneNumber)) + .orElse(false); + + } + + if (updateUserPhoneNumber) { + + session.users() + .searchForUserByUserAttributeStream(session.getContext().getRealm(), "phoneNumber", phoneNumber) + .filter(u -> !u.getId().equals(user.getId())) + .forEach(u -> { + logger.info(String.format("User %s also has phone number %s. Un-verifying.", u.getId(), phoneNumber)); + u.setSingleAttribute("phoneNumberVerified", "false"); + + u.addRequiredAction(UpdatePhoneNumberRequiredAction.PROVIDER_ID); + + //remove otp Credentials + u.credentialManager() + .getStoredCredentialsByTypeStream(PhoneOtpCredentialModel.TYPE) + .filter(c -> { + try { + PhoneOtpCredentialModel.SmsOtpCredentialData credentialData + = JsonSerialization.readValue(c.getCredentialData(), PhoneOtpCredentialModel.SmsOtpCredentialData.class); + if (Validation.isBlank(credentialData.getPhoneNumber())) { + return true; + } + return credentialData.getPhoneNumber().equals(user.getFirstAttribute("phoneNumber")); + } catch (IOException e) { + logger.warn("Unknown format Otp Credential", e); + return true; + } + }) + .map(CredentialModel::getId) + .toList() + .forEach(id -> u.credentialManager().removeStoredCredentialById(id)); + }); + + user.setSingleAttribute("phoneNumberVerified", "true"); + user.setSingleAttribute("phoneNumber", phoneNumber); + + user.removeRequiredAction(UpdatePhoneNumberRequiredAction.PROVIDER_ID); + } + + validateProcess(tokenCodeId, user); + + } + + @Override + public void validateProcess(String tokenCodeId, UserModel user) { + TokenCode entity = getEntityManager().find(TokenCode.class, tokenCodeId); + entity.setConfirmed(true); + entity.setByWhom(user.getId()); + getEntityManager().persist(entity); + } + + private void updateUserOTPCredential(UserModel user, String phoneNumber, String code) { + user.removeRequiredAction(ConfigSmsOtpRequiredAction.PROVIDER_ID); + PhoneOtpCredentialProvider ocp = (PhoneOtpCredentialProvider) session.getProvider(CredentialProvider.class, PhoneOtpCredentialProviderFactory.PROVIDER_ID); + if (ocp.isConfiguredFor(getRealm(), user, PhoneOtpCredentialModel.TYPE)) { + var credentialData = new PhoneOtpCredentialModel.SmsOtpCredentialData(phoneNumber, Utils.getOtpExpires(session)); + PhoneOtpCredentialModel.updateOtpCredential(user, credentialData, code); + } + } + + @Override + public void close() { + } +} diff --git a/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/spi/impl/DefaultVerificationCodeProviderFactory.java b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/spi/impl/DefaultVerificationCodeProviderFactory.java new file mode 100644 index 000000000..0446e901c --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider/src/main/java/cc/coopersoft/keycloak/phone/providers/spi/impl/DefaultVerificationCodeProviderFactory.java @@ -0,0 +1,32 @@ +package cc.coopersoft.keycloak.phone.providers.spi.impl; + +import cc.coopersoft.keycloak.phone.providers.spi.PhoneVerificationCodeProvider; +import cc.coopersoft.keycloak.phone.providers.spi.PhoneVerificationCodeProviderFactory; +import org.keycloak.Config; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; + +public class DefaultVerificationCodeProviderFactory implements PhoneVerificationCodeProviderFactory { + + @Override + public PhoneVerificationCodeProvider create(KeycloakSession session) { + return new DefaultPhoneVerificationCodeProvider(session); + } + + @Override + public void init(Config.Scope scope) { + } + + @Override + public void postInit(KeycloakSessionFactory keycloakSessionFactory) { + } + + @Override + public void close() { + } + + @Override + public String getId() { + return "default"; + } +} diff --git a/src/keycloak/providers/keycloak-phone-provider/src/main/resources/META-INF/changelog/token-code-changelog.xml b/src/keycloak/providers/keycloak-phone-provider/src/main/resources/META-INF/changelog/token-code-changelog.xml new file mode 100644 index 000000000..40ceae765 --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider/src/main/resources/META-INF/changelog/token-code-changelog.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/keycloak/providers/keycloak-phone-provider/src/main/resources/META-INF/keycloak-themes.json b/src/keycloak/providers/keycloak-phone-provider/src/main/resources/META-INF/keycloak-themes.json new file mode 100644 index 000000000..124e90f7b --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider/src/main/resources/META-INF/keycloak-themes.json @@ -0,0 +1,6 @@ +{ + "themes": [{ + "name" : "phone", + "types": [ "login" ] + }] +} \ No newline at end of file diff --git a/src/keycloak/providers/keycloak-phone-provider/src/main/resources/META-INF/services/cc.coopersoft.keycloak.phone.providers.spi.PhoneProviderFactory b/src/keycloak/providers/keycloak-phone-provider/src/main/resources/META-INF/services/cc.coopersoft.keycloak.phone.providers.spi.PhoneProviderFactory new file mode 100644 index 000000000..a917536a4 --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider/src/main/resources/META-INF/services/cc.coopersoft.keycloak.phone.providers.spi.PhoneProviderFactory @@ -0,0 +1 @@ +cc.coopersoft.keycloak.phone.providers.spi.impl.DefaultPhoneProviderFactory \ No newline at end of file diff --git a/src/keycloak/providers/keycloak-phone-provider/src/main/resources/META-INF/services/cc.coopersoft.keycloak.phone.providers.spi.PhoneVerificationCodeProviderFactory b/src/keycloak/providers/keycloak-phone-provider/src/main/resources/META-INF/services/cc.coopersoft.keycloak.phone.providers.spi.PhoneVerificationCodeProviderFactory new file mode 100644 index 000000000..a076e9bd3 --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider/src/main/resources/META-INF/services/cc.coopersoft.keycloak.phone.providers.spi.PhoneVerificationCodeProviderFactory @@ -0,0 +1 @@ +cc.coopersoft.keycloak.phone.providers.spi.impl.DefaultVerificationCodeProviderFactory \ No newline at end of file diff --git a/src/keycloak/providers/keycloak-phone-provider/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory b/src/keycloak/providers/keycloak-phone-provider/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory new file mode 100644 index 000000000..4bdd91649 --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory @@ -0,0 +1,8 @@ +cc.coopersoft.keycloak.phone.authentication.authenticators.browser.SmsOtpMfaAuthenticatorFactory +cc.coopersoft.keycloak.phone.authentication.authenticators.browser.PhoneUsernamePasswordForm +cc.coopersoft.keycloak.phone.authentication.authenticators.resetcred.ResetCredentialWithPhone +cc.coopersoft.keycloak.phone.authentication.authenticators.resetcred.ResetCredentialEmailWithPhone +cc.coopersoft.keycloak.phone.authentication.authenticators.directgrant.AuthenticationCodeAuthenticatorFactory +cc.coopersoft.keycloak.phone.authentication.authenticators.directgrant.PhoneNumberAuthenticatorFactory +cc.coopersoft.keycloak.phone.authentication.authenticators.directgrant.EverybodyPhoneAuthenticatorFactory +cc.coopersoft.keycloak.phone.authentication.authenticators.conditional.ConditionalPhoneProvidedFactory \ No newline at end of file diff --git a/src/keycloak/providers/keycloak-phone-provider/src/main/resources/META-INF/services/org.keycloak.authentication.FormActionFactory b/src/keycloak/providers/keycloak-phone-provider/src/main/resources/META-INF/services/org.keycloak.authentication.FormActionFactory new file mode 100644 index 000000000..d5097b807 --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider/src/main/resources/META-INF/services/org.keycloak.authentication.FormActionFactory @@ -0,0 +1,3 @@ +cc.coopersoft.keycloak.phone.authentication.forms.RegistrationPhoneVerificationCode +cc.coopersoft.keycloak.phone.authentication.forms.RegistrationRedirectParametersReader +cc.coopersoft.keycloak.phone.authentication.forms.RegistrationPhoneUserCreation diff --git a/src/keycloak/providers/keycloak-phone-provider/src/main/resources/META-INF/services/org.keycloak.authentication.RequiredActionFactory b/src/keycloak/providers/keycloak-phone-provider/src/main/resources/META-INF/services/org.keycloak.authentication.RequiredActionFactory new file mode 100644 index 000000000..819a0f22e --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider/src/main/resources/META-INF/services/org.keycloak.authentication.RequiredActionFactory @@ -0,0 +1,2 @@ +cc.coopersoft.keycloak.phone.authentication.requiredactions.UpdatePhoneNumberRequiredActionFactory +cc.coopersoft.keycloak.phone.authentication.requiredactions.ConfigSmsOtpRequiredActionFactory \ No newline at end of file diff --git a/src/keycloak/providers/keycloak-phone-provider/src/main/resources/META-INF/services/org.keycloak.connections.jpa.entityprovider.JpaEntityProviderFactory b/src/keycloak/providers/keycloak-phone-provider/src/main/resources/META-INF/services/org.keycloak.connections.jpa.entityprovider.JpaEntityProviderFactory new file mode 100644 index 000000000..3082834be --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider/src/main/resources/META-INF/services/org.keycloak.connections.jpa.entityprovider.JpaEntityProviderFactory @@ -0,0 +1 @@ +cc.coopersoft.keycloak.phone.providers.jpa.TokenCodeJpaEntityProviderFactory \ No newline at end of file diff --git a/src/keycloak/providers/keycloak-phone-provider/src/main/resources/META-INF/services/org.keycloak.credential.CredentialProviderFactory b/src/keycloak/providers/keycloak-phone-provider/src/main/resources/META-INF/services/org.keycloak.credential.CredentialProviderFactory new file mode 100644 index 000000000..c6061da58 --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider/src/main/resources/META-INF/services/org.keycloak.credential.CredentialProviderFactory @@ -0,0 +1 @@ +cc.coopersoft.keycloak.phone.credential.PhoneOtpCredentialProviderFactory \ No newline at end of file diff --git a/src/keycloak/providers/keycloak-phone-provider/src/main/resources/META-INF/services/org.keycloak.provider.Spi b/src/keycloak/providers/keycloak-phone-provider/src/main/resources/META-INF/services/org.keycloak.provider.Spi new file mode 100644 index 000000000..cc38a8f5d --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider/src/main/resources/META-INF/services/org.keycloak.provider.Spi @@ -0,0 +1,3 @@ +cc.coopersoft.keycloak.phone.providers.spi.PhoneVerificationCodeSpi +cc.coopersoft.keycloak.phone.providers.spi.PhoneSpi +cc.coopersoft.keycloak.phone.providers.spi.MessageSenderServiceSpi \ No newline at end of file diff --git a/src/keycloak/providers/keycloak-phone-provider/src/main/resources/META-INF/services/org.keycloak.services.resource.RealmResourceProviderFactory b/src/keycloak/providers/keycloak-phone-provider/src/main/resources/META-INF/services/org.keycloak.services.resource.RealmResourceProviderFactory new file mode 100644 index 000000000..2459f7b8a --- /dev/null +++ b/src/keycloak/providers/keycloak-phone-provider/src/main/resources/META-INF/services/org.keycloak.services.resource.RealmResourceProviderFactory @@ -0,0 +1 @@ +cc.coopersoft.keycloak.phone.providers.rest.SmsResourceProviderFactory \ No newline at end of file diff --git a/src/keycloak/providers/keycloak-sms-provider-dummy/pom.xml b/src/keycloak/providers/keycloak-sms-provider-dummy/pom.xml new file mode 100644 index 000000000..8ec16a0ed --- /dev/null +++ b/src/keycloak/providers/keycloak-sms-provider-dummy/pom.xml @@ -0,0 +1,51 @@ + + + 4.0.0 + + + cc.coopersoft + keycloak-phone-provider-parent + 2.3.4-snapshot + + + keycloak-sms-provider-dummy + + + + cc.coopersoft + keycloak-phone-provider + 2.3.4-snapshot + provided + + + + + + + maven-dependency-plugin + + + package + + copy + + + + + ${project.groupId} + ${project.artifactId} + ${project.version} + + + ${project.basedir}/../jars + true + true + + + + + + + diff --git a/src/keycloak/providers/keycloak-sms-provider-dummy/src/main/java/cc/coopersoft/keycloak/phone/providers/sender/DummyMessageSenderServiceProviderFactory.java b/src/keycloak/providers/keycloak-sms-provider-dummy/src/main/java/cc/coopersoft/keycloak/phone/providers/sender/DummyMessageSenderServiceProviderFactory.java new file mode 100644 index 000000000..c4a8f2a34 --- /dev/null +++ b/src/keycloak/providers/keycloak-sms-provider-dummy/src/main/java/cc/coopersoft/keycloak/phone/providers/sender/DummyMessageSenderServiceProviderFactory.java @@ -0,0 +1,32 @@ +package cc.coopersoft.keycloak.phone.providers.sender; + +import cc.coopersoft.keycloak.phone.providers.spi.MessageSenderService; +import cc.coopersoft.keycloak.phone.providers.spi.MessageSenderServiceProviderFactory; +import org.keycloak.Config; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; + +public class DummyMessageSenderServiceProviderFactory implements MessageSenderServiceProviderFactory { + + @Override + public MessageSenderService create(KeycloakSession keycloakSession) { + return new DummySmsSenderService(keycloakSession.getContext().getRealm().getDisplayName()); + } + + @Override + public void init(Config.Scope scope) { + } + + @Override + public void postInit(KeycloakSessionFactory keycloakSessionFactory) { + } + + @Override + public void close() { + } + + @Override + public String getId() { + return "dummy"; + } +} diff --git a/src/keycloak/providers/keycloak-sms-provider-dummy/src/main/java/cc/coopersoft/keycloak/phone/providers/sender/DummySmsSenderService.java b/src/keycloak/providers/keycloak-sms-provider-dummy/src/main/java/cc/coopersoft/keycloak/phone/providers/sender/DummySmsSenderService.java new file mode 100644 index 000000000..f40b0ebfe --- /dev/null +++ b/src/keycloak/providers/keycloak-sms-provider-dummy/src/main/java/cc/coopersoft/keycloak/phone/providers/sender/DummySmsSenderService.java @@ -0,0 +1,32 @@ +package cc.coopersoft.keycloak.phone.providers.sender; + +import cc.coopersoft.keycloak.phone.providers.exception.MessageSendException; +import cc.coopersoft.keycloak.phone.providers.spi.FullSmsSenderAbstractService; +import org.jboss.logging.Logger; + +import java.util.Random; + +public class DummySmsSenderService extends FullSmsSenderAbstractService { + + private static final Logger logger = Logger.getLogger(DummySmsSenderService.class); + + public DummySmsSenderService(String realmDisplay) { + super(realmDisplay); + } + + @Override + public void sendMessage(String phoneNumber, String message) throws MessageSendException { + + // here you call the method for sending messages + logger.info(String.format("To: %s >>> %s", phoneNumber, message)); + + // simulate a failure + if (new Random().nextInt(10) % 5 == 0) { + throw new MessageSendException(500, "MSG0042", "Insufficient credits to send message"); + } + } + + @Override + public void close() { + } +} diff --git a/src/keycloak/providers/keycloak-sms-provider-dummy/src/main/resources/META-INF/services/cc.coopersoft.keycloak.phone.providers.spi.MessageSenderServiceProviderFactory b/src/keycloak/providers/keycloak-sms-provider-dummy/src/main/resources/META-INF/services/cc.coopersoft.keycloak.phone.providers.spi.MessageSenderServiceProviderFactory new file mode 100644 index 000000000..4d901c286 --- /dev/null +++ b/src/keycloak/providers/keycloak-sms-provider-dummy/src/main/resources/META-INF/services/cc.coopersoft.keycloak.phone.providers.spi.MessageSenderServiceProviderFactory @@ -0,0 +1 @@ +cc.coopersoft.keycloak.phone.providers.sender.DummyMessageSenderServiceProviderFactory \ No newline at end of file diff --git a/src/keycloak/providers/keycloak-sms-provider-twilio/README.md b/src/keycloak/providers/keycloak-sms-provider-twilio/README.md new file mode 100644 index 000000000..8110ea1d4 --- /dev/null +++ b/src/keycloak/providers/keycloak-sms-provider-twilio/README.md @@ -0,0 +1,17 @@ +# Twilio SMS Sender Provider + +**Not verify in Quarkus 19.0.1** + +```sh +cp target/providers/keycloak-phone-provider.jar ${KEYCLOAK_HOME}/providers/ +cp target/providers/keycloak-phone-provider.resources.jar ${KEYCLOAK_HOME}/providers/ +cp target/providers/keycloak-sms-provider-twilio.jar ${KEYCLOAK_HOME}/providers/ + + +${KEYCLOAK_HOME}/bin/kc.sh build + +${KEYCLOAK_HOME}/bin/kc.sh start --spi-phone-default-service=twilio \ + --spi-message-sender-service-twilio-account=${account} \ + --spi-message-sender-service-twilio-token=${token} \ + --spi-message-sender-service-twilio-number=${servicePhoneNumber} +``` diff --git a/src/keycloak/providers/keycloak-sms-provider-twilio/pom.xml b/src/keycloak/providers/keycloak-sms-provider-twilio/pom.xml new file mode 100644 index 000000000..3dad948e8 --- /dev/null +++ b/src/keycloak/providers/keycloak-sms-provider-twilio/pom.xml @@ -0,0 +1,74 @@ + + + 4.0.0 + + + cc.coopersoft + keycloak-phone-provider-parent + 2.3.4-snapshot + + + keycloak-sms-provider-twilio + + + + cc.coopersoft + keycloak-phone-provider + 2.3.4-snapshot + provided + + + com.twilio.sdk + twilio + 9.2.4 + + + + + + + maven-assembly-plugin + + + package + + single + + + + + + jar-with-dependencies + + ${project.build.finalName} + false + + + + maven-dependency-plugin + + + package + + copy + + + + + ${project.groupId} + ${project.artifactId} + ${project.version} + + + ${project.basedir}/../jars + true + true + + + + + + + diff --git a/src/keycloak/providers/keycloak-sms-provider-twilio/src/main/java/cc/coopersoft/keycloak/phone/providers/sender/TwilioMessageSenderServiceProviderFactory.java b/src/keycloak/providers/keycloak-sms-provider-twilio/src/main/java/cc/coopersoft/keycloak/phone/providers/sender/TwilioMessageSenderServiceProviderFactory.java new file mode 100644 index 000000000..2db257888 --- /dev/null +++ b/src/keycloak/providers/keycloak-sms-provider-twilio/src/main/java/cc/coopersoft/keycloak/phone/providers/sender/TwilioMessageSenderServiceProviderFactory.java @@ -0,0 +1,35 @@ +package cc.coopersoft.keycloak.phone.providers.sender; + +import cc.coopersoft.keycloak.phone.providers.spi.MessageSenderService; +import cc.coopersoft.keycloak.phone.providers.spi.MessageSenderServiceProviderFactory; +import org.keycloak.Config.Scope; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; + +public class TwilioMessageSenderServiceProviderFactory implements MessageSenderServiceProviderFactory { + + private Scope config; + + @Override + public MessageSenderService create(KeycloakSession session) { + return new TwilioSmsSenderServiceProvider(config,session.getContext().getRealm().getDisplayName()); + } + + @Override + public void init(Scope config) { + this.config = config; + } + + @Override + public void postInit(KeycloakSessionFactory keycloakSessionFactory) { + } + + @Override + public void close() { + } + + @Override + public String getId() { + return "twilio"; + } +} diff --git a/src/keycloak/providers/keycloak-sms-provider-twilio/src/main/java/cc/coopersoft/keycloak/phone/providers/sender/TwilioSmsSenderServiceProvider.java b/src/keycloak/providers/keycloak-sms-provider-twilio/src/main/java/cc/coopersoft/keycloak/phone/providers/sender/TwilioSmsSenderServiceProvider.java new file mode 100644 index 000000000..85fe332b1 --- /dev/null +++ b/src/keycloak/providers/keycloak-sms-provider-twilio/src/main/java/cc/coopersoft/keycloak/phone/providers/sender/TwilioSmsSenderServiceProvider.java @@ -0,0 +1,94 @@ +package cc.coopersoft.keycloak.phone.providers.sender; + +import org.jboss.logging.Logger; +import org.keycloak.Config.Scope; + +import com.google.i18n.phonenumbers.NumberParseException; +import com.google.i18n.phonenumbers.PhoneNumberUtil; +import com.twilio.Twilio; +import com.twilio.rest.api.v2010.account.Message; +import com.twilio.type.PhoneNumber; + +import cc.coopersoft.keycloak.phone.providers.exception.MessageSendException; +import cc.coopersoft.keycloak.phone.providers.spi.FullSmsSenderAbstractService; + +public class TwilioSmsSenderServiceProvider extends FullSmsSenderAbstractService { + + private static final Logger logger = Logger.getLogger(TwilioSmsSenderServiceProvider.class); + private final Scope config; + + TwilioSmsSenderServiceProvider(Scope config, String realmDisplay) { + super(realmDisplay); + Twilio.init(config.get("account"), config.get("token")); + this.config = config; + } + + @Override + public void sendMessage(String phoneNumber, String message) throws MessageSendException { + + // Parse the phone number + var phoneNumberUtil = PhoneNumberUtil.getInstance(); + com.google.i18n.phonenumbers.Phonenumber.PhoneNumber _phoneNumber = null; + try { + _phoneNumber = phoneNumberUtil.parse(phoneNumber, null); + } catch (NumberParseException ex) { + } + + // Assume countryCode is the result from PhoneNumberUtil.getCountryCode() + if (_phoneNumber == null) { + throw new MessageSendException("Failed to parse phone number.", null); + } + String countryCode = "+" + _phoneNumber.getCountryCode(); // For example, +234 for Nigeria + + logger.info("countryCode: " + countryCode); + + // Get the Twilio phone number configuration + String twilioPhoneNumberConfig = config.get("number"); + + // Twilio phone number config is in the format: +27|600193536;+234|8031234567 + String[] twilioPhoneNumbers = twilioPhoneNumberConfig.split(";"); + + // Loop through the numbers to find the matching country code + String matchedTwilioPhoneNumber = null; + for (String twilioPhoneNumber : twilioPhoneNumbers) { + String[] twilioPhoneNumberParts = twilioPhoneNumber.split("\\|"); + if (twilioPhoneNumberParts[0].equals(countryCode)) { + matchedTwilioPhoneNumber = twilioPhoneNumberParts[1]; // Set the Twilio phone number for the matching country code + break; // Exit the loop once the matching country is found + } + } + + if (matchedTwilioPhoneNumber == null) { + throw new MessageSendException("No matching Twilio phone number found for the country code: " + countryCode, null); + } else // append the country code to the Twilio phone number + { + matchedTwilioPhoneNumber = countryCode + matchedTwilioPhoneNumber; + } + + try { + Message msg = Message.creator( + new PhoneNumber(phoneNumber), + new PhoneNumber(matchedTwilioPhoneNumber), + message).create(); + if (msg.getStatus() == Message.Status.FAILED) { + logger.error("Message send failed!"); + // Construct an error message + String errorMsg = String.format("Message send failed with status: %s, error code: %s, error message: %s", + msg.getStatus(), + msg.getErrorCode(), + msg.getErrorMessage()); + // Throw the exception with the error message and no cause + throw new MessageSendException(errorMsg, null); + } + } catch (Exception e) { + // If an exception occurs, wrap it in MessageSendException + String errorMsg = "Failed to send message due to an exception."; + logger.error(errorMsg, e); + throw new MessageSendException(errorMsg, e); + } + } + + @Override + public void close() { + } +} diff --git a/src/keycloak/providers/keycloak-sms-provider-twilio/src/main/resources/META-INF/services/cc.coopersoft.keycloak.phone.providers.spi.MessageSenderServiceProviderFactory b/src/keycloak/providers/keycloak-sms-provider-twilio/src/main/resources/META-INF/services/cc.coopersoft.keycloak.phone.providers.spi.MessageSenderServiceProviderFactory new file mode 100644 index 000000000..a0d83f36d --- /dev/null +++ b/src/keycloak/providers/keycloak-sms-provider-twilio/src/main/resources/META-INF/services/cc.coopersoft.keycloak.phone.providers.spi.MessageSenderServiceProviderFactory @@ -0,0 +1 @@ +cc.coopersoft.keycloak.phone.providers.sender.TwilioMessageSenderServiceProviderFactory \ No newline at end of file diff --git a/src/keycloak/providers/pom.xml b/src/keycloak/providers/pom.xml new file mode 100644 index 000000000..403cf1d33 --- /dev/null +++ b/src/keycloak/providers/pom.xml @@ -0,0 +1,175 @@ + + + 4.0.0 + + cc.coopersoft + keycloak-phone-provider-parent + pom + 2.3.4-snapshot + + keycloak-phone-provider + keycloak phone support. + https://github.com/cooperlyt/keycloak-phone-provider + + + + MIT License + https://www.opensource.org/licenses/mit-license.php + repo + + + + + ${project.version} + scm:git@github.com:cooperlyt/keycloak-phone-provider.git + scm:git@github.com:cooperlyt/keycloak-phone-provider.git + https://github.com/cooperlyt/keycloak-phone-provider + + + + + cooperlyt + cooper lee + lyt3131@163.com + https://cooperlyt.github.io + +8 + + Developer + + + + + + UTF-8 + 17 + 17 + 17 + 22.0.1 + + + + keycloak-phone-provider + keycloak-phone-provider.resources + keycloak-sms-provider-dummy + keycloak-sms-provider-twilio + + + + + org.projectlombok + lombok + 1.18.26 + provided + + + org.keycloak + keycloak-core + ${version.keycloak} + provided + + + org.keycloak + keycloak-services + ${version.keycloak} + provided + + + org.keycloak + keycloak-server-spi + ${version.keycloak} + provided + + + org.keycloak + keycloak-server-spi-private + ${version.keycloak} + provided + + + + + com.google.auto.service + auto-service + 1.0-rc7 + provided + + + + + org.slf4j + slf4j-api + 1.7.32 + + + org.slf4j + slf4j-simple + 1.7.32 + runtime + + + + + org.keycloak + keycloak-core + ${version.keycloak} + provided + + + org.slf4j + slf4j-api + + + + + org.keycloak + keycloak-services + ${version.keycloak} + provided + + + org.slf4j + slf4j-api + + + + + + + + + + + org.codehaus.mojo + versions-maven-plugin + 2.10.0 + + false + + + + false + maven-resources-plugin + + + copy-resources + package + + copy-resources + + + ${basedir}/target + + + docker + true + + + + + + + + + diff --git a/src/keycloak/scripts/users.sh b/src/keycloak/scripts/users.sh index e5f919466..583df22ff 100644 --- a/src/keycloak/scripts/users.sh +++ b/src/keycloak/scripts/users.sh @@ -39,7 +39,6 @@ if [ ! -z "${ADMIN_USER}" ]; then "email": "'"${ADMIN_USER}"'", "attributes": { "dateOfBirth": ["01/01/2001"], - "phoneNumber": ["202-918-2132"], "country": ["Afghanistan"], "gender": ["Male"] }, @@ -87,7 +86,6 @@ if [ ! -z "${ORG_ADMIN_USER}" ]; then "email": "'"${ORG_ADMIN_USER}"'", "attributes": { "dateOfBirth": ["01/01/2001"], - "phoneNumber": ["202-918-2132"], "country": ["Afghanistan"], "gender": ["Male"] }, @@ -133,7 +131,6 @@ if [ ! -z "${YOMA_SYSTEM_USER}" ]; then "email": "'"${YOMA_SYSTEM_USER}"'", "attributes": { "dateOfBirth": ["01/01/2001"], - "phoneNumber": ["202-918-2132"], "country": ["Afghanistan"], "gender": ["Male"] }, @@ -179,7 +176,6 @@ if [ ! -z "${TEST_USER}" ]; then "email": "'"${TEST_USER}"'", "attributes": { "dateOfBirth": ["01/01/2001"], - "phoneNumber": ["202-918-2132"], "country": ["Afghanistan"], "gender": ["Male"] }, diff --git a/src/keycloak/themes/yoma/login/error.ftl b/src/keycloak/themes/yoma/login/error.ftl deleted file mode 100644 index 4ce5fe419..000000000 --- a/src/keycloak/themes/yoma/login/error.ftl +++ /dev/null @@ -1,15 +0,0 @@ -<#import "template.ftl" as layout> -<@layout.registrationLayout displayMessage=false; section> - <#if section = "title"> - ${msg("errorTitle")} - <#elseif section = "header"> - ${msg("errorTitleHtml")} - <#elseif section = "form"> -
-

${message.summary}

- <#if client?? && client.baseUrl?has_content> - ${msg("backToApplication")} - -
- - diff --git a/src/keycloak/themes/yoma/login/login-reset-password.ftl b/src/keycloak/themes/yoma/login/login-reset-password.ftl deleted file mode 100644 index c28d68dea..000000000 --- a/src/keycloak/themes/yoma/login/login-reset-password.ftl +++ /dev/null @@ -1,47 +0,0 @@ -<#import "template.ftl" as layout> - <@layout.registrationLayout displayInfo=false displayMessage=!messagesPerField.existsError('username'); section> - <#if section="header"> - ${msg("emailForgotTitle")} - <#elseif section="form"> -

- ${msg("noWorries")} -

-
-
-
- - - <#if messagesPerField.existsError('username')> - - ${kcSanitize(messagesPerField.get('username'))?no_esc} - - -
-
- -
- - diff --git a/src/keycloak/themes/yoma/login/login-update-password.ftl b/src/keycloak/themes/yoma/login/login-update-password.ftl deleted file mode 100644 index 6fe4fcfa0..000000000 --- a/src/keycloak/themes/yoma/login/login-update-password.ftl +++ /dev/null @@ -1,86 +0,0 @@ -<#import "template.ftl" as layout> -<@layout.registrationLayout displayMessage=!messagesPerField.existsError('password','password-confirm'); section> - <#if section = "header"> -

${msg("updatePasswordTitle")}

- <#elseif section = "form"> -
- - - -
-
- -
-
-
- - -
- - <#if messagesPerField.existsError('password')> - - ${kcSanitize(messagesPerField.get('password'))?no_esc} - - - -
-
Password requirements:
-

10 characters

-

1 lower case

-

1 upper case

-

1 number

-

Not email

-
-
-
- -
-
- -
-
-
- - -
- - <#if messagesPerField.existsError('password-confirm')> - - ${kcSanitize(messagesPerField.get('password-confirm'))?no_esc} - - -
-
- -
- -
- -
- <#if isAppInitiatedAction??> - - - <#else> - - -
- -
- - - - - - diff --git a/src/keycloak/themes/yoma/login/login-update-profile.ftl b/src/keycloak/themes/yoma/login/login-update-profile.ftl deleted file mode 100644 index be579b01b..000000000 --- a/src/keycloak/themes/yoma/login/login-update-profile.ftl +++ /dev/null @@ -1,99 +0,0 @@ -<#import "template.ftl" as layout> -<@layout.registrationLayout displayMessage=!messagesPerField.existsError('username','email','firstName','lastName'); section> - <#if section = "header"> - ${msg("loginProfileTitle")} - <#elseif section = "form"> -
- <#if user.editUsernameAllowed> -
-
- -
-
- - - <#if messagesPerField.existsError('username')> - - ${kcSanitize(messagesPerField.get('username'))?no_esc} - - -
-
- - <#if user.editEmailAllowed> -
-
- -
-
- - - <#if messagesPerField.existsError('email')> - - ${kcSanitize(messagesPerField.get('email'))?no_esc} - - -
-
- - -
-
- -
-
- - - <#if messagesPerField.existsError('firstName')> - - ${kcSanitize(messagesPerField.get('firstName'))?no_esc} - - -
-
- -
-
- -
-
- - - <#if messagesPerField.existsError('lastName')> - - ${kcSanitize(messagesPerField.get('lastName'))?no_esc} - - -
-
- -
-
-
-
-
- -
- <#if isAppInitiatedAction??> - - - <#else> - - -
-
-
- - diff --git a/src/keycloak/themes/yoma/login/login-username.ftl b/src/keycloak/themes/yoma/login/login-username.ftl deleted file mode 100644 index ce31052d7..000000000 --- a/src/keycloak/themes/yoma/login/login-username.ftl +++ /dev/null @@ -1,105 +0,0 @@ -<#import "template.ftl" as layout> - <@layout.registrationLayout displayMessage=!messagesPerField.existsError('username') displayInfo=(realm.password && realm.registrationAllowed && !registrationDisabled??); section> - <#if section="header"> -

${msg("loginAccountTitle")}

- - <#elseif section="form"> -
-
- <#if realm.password> -
- <#if !usernameHidden??> -
- - - <#if messagesPerField.existsError('username')> - - ${kcSanitize(messagesPerField.get('username'))?no_esc} - - -
- -
-
- <#if realm.rememberMe && !usernameHidden??> -
- -
- -
-
-
-
-
- -
-
- <#elseif section="info"> - <#if realm.password && realm.registrationAllowed && !registrationDisabled??> - -
- -
- - <#elseif section="socialProviders"> - <#if realm.password && social.providers??> -
-

- ${msg("identity-provider-login-label")} -

- -
- - - diff --git a/src/keycloak/themes/yoma/login/login-verify-email.ftl b/src/keycloak/themes/yoma/login/login-verify-email.ftl deleted file mode 100644 index a618e2ade..000000000 --- a/src/keycloak/themes/yoma/login/login-verify-email.ftl +++ /dev/null @@ -1,20 +0,0 @@ -<#import "template.ftl" as layout> -<@layout.registrationLayout displayInfo=true; section> - <#if section = "header"> - ${msg("emailVerifyTitle")} -
- email-icon -
- <#elseif section = "form"> -

${msg("emailVerifyInstruction1",user.email)}

-

${user.email}

- <#elseif section = "info"> -
-

- ${msg("emailVerifyInstruction2")} -

-

${msg("emailVerifyInstruction3")}

-
- ${msg("resendEmailVerificationBtn")} - - diff --git a/src/keycloak/themes/yoma/login/login.ftl b/src/keycloak/themes/yoma/login/login.ftl deleted file mode 100644 index 262f30db3..000000000 --- a/src/keycloak/themes/yoma/login/login.ftl +++ /dev/null @@ -1,103 +0,0 @@ -<#-- - Login template - This file is used to render the login page. ---> -<#import "template.ftl" as layout> - -<@layout.registrationLayout displayInfo=(realm.password && realm.registrationAllowed && !registrationDisabled??); section> - <#if section == "header"> -

${msg("loginAccountTitle")}

- <#elseif section == "form"> -
- <#if !usernameHidden??> -
- - - <#if messagesPerField.existsError('username')> - - ${kcSanitize(messagesPerField.get('username'))?no_esc} - - -
- -
- -
- - -
- <#if messagesPerField.existsError('password')> - - ${kcSanitize(messagesPerField.get('password'))?no_esc} - - -
-
-
- <#if realm.resetPasswordAllowed> - ${msg("doForgotPassword")} - -
-
- <#if realm.rememberMe && !usernameHidden??> -
- -
- -
-
-
- -
-
- <#elseif section == "info"> - <#if realm.password && realm.registrationAllowed && !registrationDisabled??> - - - <#elseif section == "socialProviders"> - <#if realm.password && social.providers??> -
-

${msg("identity-provider-login-label")}

- -
- - - - diff --git a/src/keycloak/themes/yoma/login/page-expired.ftl b/src/keycloak/themes/yoma/login/page-expired.ftl deleted file mode 100644 index efa30efc7..000000000 --- a/src/keycloak/themes/yoma/login/page-expired.ftl +++ /dev/null @@ -1,11 +0,0 @@ -<#import "template.ftl" as layout> -<@layout.registrationLayout; section> - <#if section = "header"> - ${msg("pageExpiredTitle")} - <#elseif section = "form"> -
-

${msg("pageExpiredMsg1")}

- ${msg("backToLogin")} -
- - diff --git a/src/keycloak/themes/yoma/login/register-commons.ftl b/src/keycloak/themes/yoma/login/register-commons.ftl deleted file mode 100644 index 7007797ee..000000000 --- a/src/keycloak/themes/yoma/login/register-commons.ftl +++ /dev/null @@ -1,27 +0,0 @@ -<#macro termsAcceptance> - <#if termsAcceptanceRequired??> -
-
- ${msg("termsTitle")} -
- ${kcSanitize(msg("termsText"))?no_esc} -
-
-
-
-
- - -
- <#if messagesPerField.existsError('termsAccepted')> -
- - ${kcSanitize(messagesPerField.get('termsAccepted'))?no_esc} - -
- -
- - diff --git a/src/keycloak/themes/yoma/login/register-user-profile.ftl b/src/keycloak/themes/yoma/login/register-user-profile.ftl deleted file mode 100644 index 4896bff5e..000000000 --- a/src/keycloak/themes/yoma/login/register-user-profile.ftl +++ /dev/null @@ -1,130 +0,0 @@ -<#import "template.ftl" as layout> - <#import "user-profile-commons.ftl" as userProfileCommons> - <@layout.registrationLayout displayMessage=messagesPerField.exists('global') displayRequiredFields=false; section> - <#if section="header"> - ${msg("registerTitle")} - <#elseif section="form"> -
- -
-
- <@userProfileCommons.userProfileFormFields; callback, attribute> - <#if callback="afterField"> - <#-- render password fields just under the username or email (if used as username) --> - <#if passwordRequired?? && (attribute.name=='username' || (attribute.name=='email' && realm.registrationEmailAsUsername))> -
-
- * -
${msg("passwordInstructions")}
-
-
-
- - -
- - <#if messagesPerField.existsError('password')> - - ${kcSanitize(messagesPerField.get('password'))?no_esc} - - - -
-
Password requirements:
-

10 Characters Long

-

1 lower case

-

1 UPPER CASE

-

1 Numb3r

-

Different from email

-
-
-
-
-
- * -
-
-
- - - -
- <#if messagesPerField.existsError('password-confirm')> - - ${kcSanitize(messagesPerField.get('password-confirm'))?no_esc} - - -
-
- - - - -
-
- - -
-
- <#if recaptchaRequired??> -
-
-
-
-
- -
- -
-
-
-
-
-
- - - -
- -
-
-
-
- - - - - - - diff --git a/src/keycloak/themes/yoma/login/register.ftl b/src/keycloak/themes/yoma/login/register.ftl deleted file mode 100644 index 96fb001cf..000000000 --- a/src/keycloak/themes/yoma/login/register.ftl +++ /dev/null @@ -1,164 +0,0 @@ -<#import "template.ftl" as layout> -<#import "register-commons.ftl" as registerCommons> -<@layout.registrationLayout displayMessage=!messagesPerField.existsError('firstName','lastName','email','username','password','password-confirm','termsAccepted'); section> - <#if section = "header"> - ${msg("registerTitle")} - <#elseif section = "form"> -
-
-
- -
-
- - - <#if messagesPerField.existsError('firstName')> - - ${kcSanitize(messagesPerField.get('firstName'))?no_esc} - - -
-
- -
-
- -
-
- - - <#if messagesPerField.existsError('lastName')> - - ${kcSanitize(messagesPerField.get('lastName'))?no_esc} - - -
-
- -
-
- -
-
- - - <#if messagesPerField.existsError('email')> - - ${kcSanitize(messagesPerField.get('email'))?no_esc} - - -
-
- - <#if !realm.registrationEmailAsUsername> -
-
- -
-
- - - <#if messagesPerField.existsError('username')> - - ${kcSanitize(messagesPerField.get('username'))?no_esc} - - -
-
- - - <#if passwordRequired??> -
-
- -
-
- - - <#if messagesPerField.existsError('password')> - - ${kcSanitize(messagesPerField.get('password'))?no_esc} - - - -
-
Password requirements:
-

10 characters

-

1 lower case

-

1 upper case

-

1 number

-

Not email

-
-
-
- -
-
- -
-
- - - <#if messagesPerField.existsError('password-confirm')> - - ${kcSanitize(messagesPerField.get('password-confirm'))?no_esc} - - -
-
- - - <@registerCommons.termsAcceptance/> - - <#if recaptchaRequired??> -
-
-
-
-
- - - -
- - - - - - diff --git a/src/keycloak/themes/yoma/login/resources/css/passwordIndicator.css b/src/keycloak/themes/yoma/login/resources/css/passwordIndicator.css deleted file mode 100644 index e5d0ab826..000000000 --- a/src/keycloak/themes/yoma/login/resources/css/passwordIndicator.css +++ /dev/null @@ -1,26 +0,0 @@ -#password-requirements { - flex-direction: column; - margin-left: 35px; - margin-right: 35px; - display: none; -} - -#password-requirements #label { - margin-top: 10px; - margin-bottom: 5px; - color: black; -} -#password-requirements > p { - display: flex; - flex-direction: row; - align-items: center; - gap: 1rem; - font-size: 12px; - margin: 0px; -} - -#password-requirements > p > img { - width: 20px; - height: 20px; - margin-right: -5px; -} diff --git a/src/keycloak/themes/yoma/login/resources/js/passwordIndicator.js b/src/keycloak/themes/yoma/login/resources/js/passwordIndicator.js deleted file mode 100644 index d0ec0a9aa..000000000 --- a/src/keycloak/themes/yoma/login/resources/js/passwordIndicator.js +++ /dev/null @@ -1,46 +0,0 @@ -function passwordIndicator(resourcesPath, emailSelector, passwordSelector) { - var email = document.querySelector(emailSelector).value; - var password = document.querySelector(passwordSelector).value; - - const requirements = { - length: password.length >= 10, - lowercase: /[a-z]/.test(password), - uppercase: /[A-Z]/.test(password), - number: /\d/.test(password), - email: !email || !password.includes(email), - }; - - const passwordRequirements = document.querySelector("#password-requirements"); - - for (const requirement in requirements) { - const element = passwordRequirements.querySelector(`#${requirement}`); - let imgElement = element.querySelector("img"); - - if (!imgElement) { - imgElement = document.createElement("img"); - - element.insertBefore(imgElement, element.firstChild); - } - - if (requirements[requirement]) { - imgElement.src = `${resourcesPath}/img/icon-check.png`; - } else { - imgElement.src = `${resourcesPath}/img/icon-cross.png`; - } - } - - passwordRequirements.style.display = "flex"; -} - -function togglePassword(passwordSelector, toggleSelector) { - var password = document.querySelector(passwordSelector); - var toggle = document.querySelector(toggleSelector); - - if (password.type === "password") { - password.type = "text"; - toggle.className = "fa fa-eye"; - } else { - password.type = "password"; - toggle.className = "fa fa-eye-slash"; - } -} diff --git a/src/keycloak/themes/yoma/login/theme.properties b/src/keycloak/themes/yoma/login/theme.properties deleted file mode 100644 index 351b4d5d2..000000000 --- a/src/keycloak/themes/yoma/login/theme.properties +++ /dev/null @@ -1,2 +0,0 @@ -parent=keycloak -styles=web_modules/@patternfly/react-core/dist/styles/base.css web_modules/@patternfly/react-core/dist/styles/app.css node_modules/patternfly/dist/css/patternfly.min.css node_modules/patternfly/dist/css/patternfly-additions.min.css css/login.css css/styles.css diff --git a/src/keycloak/themes/yoma/login/update-email.ftl b/src/keycloak/themes/yoma/login/update-email.ftl deleted file mode 100644 index 4e6502b81..000000000 --- a/src/keycloak/themes/yoma/login/update-email.ftl +++ /dev/null @@ -1,43 +0,0 @@ -<#import "template.ftl" as layout> -<@layout.registrationLayout displayMessage=!messagesPerField.existsError('email'); section> - <#if section = "header"> - ${msg("updateEmailTitle")} - <#elseif section = "form"> -
-

${msg("updateEmailText")}

-
-
- -
-
- - - <#if messagesPerField.existsError('email')> - - ${kcSanitize(messagesPerField.get('email'))?no_esc} - - -
-
- -
-
-
-
-
- -
- <#if isAppInitiatedAction??> - - - <#else> - - -
-
-
- - diff --git a/src/keycloak/themes/yoma/login/update-user-profile.ftl b/src/keycloak/themes/yoma/login/update-user-profile.ftl deleted file mode 100644 index b26655f82..000000000 --- a/src/keycloak/themes/yoma/login/update-user-profile.ftl +++ /dev/null @@ -1,28 +0,0 @@ -<#import "template.ftl" as layout> -<#import "user-profile-commons.ftl" as userProfileCommons> -<@layout.registrationLayout displayMessage=messagesPerField.exists('global') displayRequiredFields=true; section> - <#if section = "header"> - ${msg("loginProfileTitle")} - <#elseif section = "form"> -
- - <@userProfileCommons.userProfileFormFields/> - -
-
-
-
-
- -
- <#if isAppInitiatedAction??> - - - <#else> - - -
-
-
- - diff --git a/src/web/src/api/models/myOpportunity.ts b/src/web/src/api/models/myOpportunity.ts index 0d68dde57..6b1657ff4 100644 --- a/src/web/src/api/models/myOpportunity.ts +++ b/src/web/src/api/models/myOpportunity.ts @@ -53,6 +53,7 @@ export interface MyOpportunityInfo { id: string; userId: string; userEmail: string; + userPhoneNumber: string; userDisplayName: string | null; userCountry: string | null; userEducation: string | null; diff --git a/src/web/src/api/models/organisation.ts b/src/web/src/api/models/organisation.ts index c3bd1a424..1950e6d8c 100644 --- a/src/web/src/api/models/organisation.ts +++ b/src/web/src/api/models/organisation.ts @@ -24,7 +24,7 @@ export interface OrganizationRequestBase { educationProviderDocuments: FormFile[] | null; businessDocuments: FormFile[] | null; addCurrentUserAsAdmin: boolean; - adminEmails: string[]; + admins: string[]; registrationDocumentsDelete: string[] | null; educationProviderDocumentsDelete: string[] | null; businessDocumentsDelete: string[] | null; @@ -80,14 +80,6 @@ export interface Organization { administrators: UserInfo[] | null; } -export interface UserInfo { - id: string; - email: string; - firstName: string; - surname: string; - displayName: string | null; -} - export interface OrganizationDocument { fileId: string; type: string; @@ -136,8 +128,11 @@ export interface OrganizationRequestUpdateStatus { export interface UserInfo { id: string; - email: string; + username: string; + email: string | null; firstName: string; surname: string; displayName: string | null; + phoneNumber: string | null; + countryId: string | null; } diff --git a/src/web/src/api/models/user.ts b/src/web/src/api/models/user.ts index a887208ca..f1d4d3aa3 100644 --- a/src/web/src/api/models/user.ts +++ b/src/web/src/api/models/user.ts @@ -8,6 +8,7 @@ export interface User { surname: string; displayName: string | null; phoneNumber: string | null; + phoneNumberConfirmed: boolean; countryId: string | null; countryOfResidenceId: string | null; genderId: string | null; @@ -25,6 +26,7 @@ export interface User { } export interface UserRequestProfile extends UserRequestBase { + updatePhoneNumber: boolean; resetPassword: boolean; } @@ -48,6 +50,7 @@ export interface UserProfile { surname: string; displayName: string | null; phoneNumber: string | null; + phoneNumberConfirmed: boolean; countryId: string | null; educationId: string | null; genderId: string | null; diff --git a/src/web/src/components/Global.tsx b/src/web/src/components/Global.tsx index 02eacd9a0..f609aec52 100644 --- a/src/web/src/components/Global.tsx +++ b/src/web/src/components/Global.tsx @@ -188,7 +188,11 @@ export const Global: React.FC = () => { const cookies = parseCookies(); const existingSessionCookieValue = cookies[COOKIE_KEYCLOAK_SESSION]; - if (existingSessionCookieValue) { + // check for 'signInAgain' query parameter (user profile email/phone/password reset) + const urlParams = new URLSearchParams(window.location.search); + const signInAgain = urlParams.get("signInAgain"); + + if (existingSessionCookieValue || signInAgain) { // sign in with keycloak handleUserSignIn(currentLanguage); } @@ -519,7 +523,6 @@ export const Global: React.FC = () => { UserProfileFilterOptions.FIRSTNAME, UserProfileFilterOptions.SURNAME, UserProfileFilterOptions.DISPLAYNAME, - UserProfileFilterOptions.PHONENUMBER, UserProfileFilterOptions.COUNTRY, UserProfileFilterOptions.EDUCATION, UserProfileFilterOptions.GENDER, diff --git a/src/web/src/components/Opportunity/OpportunityCompletionRead.tsx b/src/web/src/components/Opportunity/OpportunityCompletionRead.tsx index 555c350b6..b35294f8e 100644 --- a/src/web/src/components/Opportunity/OpportunityCompletionRead.tsx +++ b/src/web/src/components/Opportunity/OpportunityCompletionRead.tsx @@ -121,7 +121,9 @@ export const OpportunityCompletionRead: React.FC = ({

{data?.userDisplayName}

-

{data?.userEmail}

+

+ {data?.userEmail ?? data?.userPhoneNumber} +

{data?.userCountry} diff --git a/src/web/src/components/Organisation/Upsert/OrgAdminsEdit.tsx b/src/web/src/components/Organisation/Upsert/OrgAdminsEdit.tsx index 47c5b0d6f..f3e7f972a 100644 --- a/src/web/src/components/Organisation/Upsert/OrgAdminsEdit.tsx +++ b/src/web/src/components/Organisation/Upsert/OrgAdminsEdit.tsx @@ -5,7 +5,7 @@ import CreatableSelect from "react-select/creatable"; import zod from "zod"; import { type OrganizationRequestBase } from "~/api/models/organisation"; import { DELIMETER_PASTE_MULTI } from "~/lib/constants"; -import { validateEmail } from "~/lib/validate"; +import { validateEmail, validatePhoneNumber } from "~/lib/validate"; export interface InputProps { organisation: OrganizationRequestBase | null; @@ -25,33 +25,36 @@ export const OrgAdminsEdit: React.FC = ({ const schema = zod .object({ addCurrentUserAsAdmin: zod.boolean().optional(), - adminEmails: zod.array(zod.string().email()).optional(), + admins: zod.array(zod.string()).optional(), ssoClientIdInbound: zod.string().optional(), ssoClientIdOutbound: zod.string().optional(), }) .superRefine((values, ctx) => { - // adminEmails is required if addCurrentUserAsAdmin is false + // admins is required if addCurrentUserAsAdmin is false if ( !values.addCurrentUserAsAdmin && - (values.adminEmails == null || values.adminEmails?.length < 1) + (values.admins == null || values.admins?.length < 1) ) { ctx.addIssue({ message: - "At least one Admin Additional Email is required if you are not the organisation admin.", + "At least one user is required if you are not the organisation admin.", code: zod.ZodIssueCode.custom, - path: ["adminEmails"], + path: ["admins"], }); } }) .refine( (data) => { - // validate all items are valid email addresses - return data.adminEmails?.every((email) => validateEmail(email)); + // validate all items are valid email addresses or phone numbers + return data.admins?.every( + (userName) => + validateEmail(userName) || validatePhoneNumber(userName), + ); }, { message: - "Please enter valid email addresses e.g. name@gmail.com. One or more email address are wrong.", - path: ["adminEmails"], + "Please enter valid email addresses (name@gmail.com) or phone numbers (+27125555555).", + path: ["admins"], }, ); @@ -111,13 +114,13 @@ export const OrgAdminsEdit: React.FC = ({ ( ({ + options={organisation?.admins?.map((val) => ({ label: val, value: val, }))} @@ -144,11 +147,11 @@ export const OrgAdminsEdit: React.FC = ({ /> )} /> - {formState.errors.adminEmails && ( + {formState.errors.admins && ( )} diff --git a/src/web/src/components/User/UserProfileForm.tsx b/src/web/src/components/User/UserProfileForm.tsx index be7c81aaa..abb0e969c 100644 --- a/src/web/src/components/User/UserProfileForm.tsx +++ b/src/web/src/components/User/UserProfileForm.tsx @@ -24,6 +24,9 @@ import { useSession } from "next-auth/react"; import { useSetAtom } from "jotai"; import { userProfileAtom } from "~/lib/store"; import { Loading } from "../Status/Loading"; +import FormMessage, { FormMessageType } from "../Common/FormMessage"; +import { validateEmail } from "~/lib/validate"; +import { handleUserSignOut } from "~/lib/authUtils"; export enum UserProfileFilterOptions { EMAIL = "email", @@ -68,6 +71,7 @@ export const UserProfileForm: React.FC<{ genderId: userProfile?.genderId ?? "", dateOfBirth: userProfile?.dateOfBirth ?? "", resetPassword: false, + updatePhoneNumber: false, }); const queryClient = useQueryClient(); @@ -86,17 +90,29 @@ export const UserProfileForm: React.FC<{ }); const schema = zod.object({ - email: zod.string().email().min(1, "Email is required."), + email: zod.string().refine( + (value) => { + // If userProfile.email exists, email is required and must be valid email + if (userProfile?.email) { + return value.length > 0 && validateEmail(value); + } + // If userProfile.email does not exist, email is optional + return true; + }, + { + message: "Email is required.", + }, + ), firstName: zod.string().min(1, "First name is required."), surname: zod.string().min(1, "Last name is required."), displayName: zod.string().min(1, "Display name is required"), - phoneNumber: zod - .string() - .min(1, "Phone number is required.") - .regex( - /^[\\+]?[(]?[0-9]{3}[)]?[-\\s\\.]?[0-9]{3}[-\\s\\.]?[0-9]{4,6}$/, - "Phone number is invalid", - ), + // phoneNumber: zod + // .string() + // .min(1, "Phone number is required.") + // .regex( + // /^[\\+]?[(]?[0-9]{3}[)]?[-\\s\\.]?[0-9]{3}[-\\s\\.]?[0-9]{4,6}$/, + // "Phone number is invalid", + // ), countryId: zod.string().min(1, "Country is required."), educationId: zod.string().min(1, "Education is required."), genderId: zod.string().min(1, "Gender is required."), @@ -110,13 +126,18 @@ export const UserProfileForm: React.FC<{ }) .max(new Date(), { message: "Date of Birth cannot be in the future." }), resetPassword: zod.boolean(), + updatePhoneNumber: zod.boolean(), }); const form = useForm({ mode: "all", resolver: zodResolver(schema), }); - const { register, handleSubmit, formState, reset } = form; + const { register, handleSubmit, formState, reset, watch } = form; + const watchEmail = watch("email"); + const watchUpdatePhoneNumber = watch("updatePhoneNumber"); + const watchResetPassword = watch("resetPassword"); + const watchPhoneNumber = watch("phoneNumber"); // set default values useEffect(() => { @@ -142,7 +163,7 @@ export const UserProfileForm: React.FC<{ if (!formData.educationId) formData.educationId = ""; if (!formData.genderId) formData.genderId = ""; - formData.resetPassword = false; + //formData.resetPassword = false; // reset form // setTimeout is needed to prevent the form from being reset before the default values are set @@ -183,6 +204,17 @@ export const UserProfileForm: React.FC<{ // 📊 GOOGLE ANALYTICS: track event trackGAEvent(GA_CATEGORY_USER, GA_ACTION_USER_PROFILE_UPDATE, ""); + // check if sign-in again is required + const emailUpdated = + (data.email ?? "").toLowerCase() !== + (userProfile.email ?? "").toLowerCase(); + + if (emailUpdated || data.updatePhoneNumber || data.resetPassword) { + // signout from keycloak + handleUserSignOut(true); + return; + } + // update userProfile Atom (used by NavBar/UserMenu.tsx, refresh profile picture) setUserProfileAtom(userProfile); @@ -247,9 +279,9 @@ export const UserProfileForm: React.FC<{ + {formState.errors.email && ( )} + + {/* show message if email is different from the current email */} + {(watchEmail ?? "").toLowerCase() !== + (userProfile?.email ?? "") && ( +

+ + Updating your email will sign you out. Check your email to + verify it when you sign in again. + +
+ )} + + )} + + {filterOptions?.includes(UserProfileFilterOptions.PHONENUMBER) && ( +
+ + + {watchPhoneNumber && ( + <> + + {formState.errors.phoneNumber && ( + + )} + + )} + + {/* allow update phone number if no phone number specified */} + + + {watchUpdatePhoneNumber && ( + + You will need to sign in again and will be prompted to change + your phone number. + + )} +
+ )} + + {filterOptions?.includes(UserProfileFilterOptions.RESETPASSWORD) && ( +
+ + + + + {formState.errors.resetPassword && ( + + )} + + {watchResetPassword && ( + + {watchEmail + ? "You will receive an email with instructions to reset your email." + : formData.phoneNumber + ? "You will be prompted to change your password upon signing in again." + : "Changing your password will require you to sign in again."} + + )}
)} @@ -327,8 +456,9 @@ export const UserProfileForm: React.FC<{ {formState.errors.phoneNumber && (