From d4465ebf9d5710582aef014228b81643558cab96 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Thu, 16 May 2024 15:21:34 +0200 Subject: [PATCH 01/19] Use hg 6 by default --- backend/Testing/Testing.csproj | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/Testing/Testing.csproj b/backend/Testing/Testing.csproj index 723f085e1..57b5d8306 100644 --- a/backend/Testing/Testing.csproj +++ b/backend/Testing/Testing.csproj @@ -7,9 +7,9 @@ false $(MSBuildProjectDirectory) - - - 3 + + + 6 From 52cb50983ea5788bd50d9f39c7e88852a9784dd9 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Thu, 16 May 2024 13:59:43 +0200 Subject: [PATCH 02/19] Don't expose port 3000 in k8s --- skaffold.yaml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/skaffold.yaml b/skaffold.yaml index 371099516..8904531fc 100644 --- a/skaffold.yaml +++ b/skaffold.yaml @@ -48,11 +48,6 @@ portForward: resourceName: lexbox namespace: languagedepot port: 18888 - # Svelte-Kit frontend - - resourceType: Service - resourceName: ui - namespace: languagedepot - port: 3000 - resourceType: Service resourceName: db namespace: languagedepot From 0e6739a9b73f3b2ebf3ce1cb580153f1861ec275 Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Fri, 17 May 2024 17:00:08 +0700 Subject: [PATCH 03/19] Set up languagedepot namespace in task setup (#803) This will avoid Skaffold deleting the languagedepot namespace when you run `skaffold delete`, so it doesn't have to be re-created each time. --- Taskfile.yml | 1 + deployment/local-dev/ingress-deployment.yaml | 42 ++++++++------------ deployment/{base => setup}/namespace.yaml | 0 3 files changed, 18 insertions(+), 25 deletions(-) rename deployment/{base => setup}/namespace.yaml (100%) diff --git a/Taskfile.yml b/Taskfile.yml index ad80af9d0..317aafe50 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -34,6 +34,7 @@ tasks: - echo "#OTEL_SDK_DISABLED=true" >> deployment/local-dev/local.env - echo "GOOGLE_OAUTH_CLIENT_ID=__REPLACE__.apps.googleusercontent.com" >> deployment/local-dev/local.env - echo "GOOGLE_OAUTH_CLIENT_SECRET=__REPLACE__" >> deployment/local-dev/local.env + - kubectl --context=docker-desktop apply -f deployment/setup/namespace.yaml setup-win: platforms: [ windows ] cmds: diff --git a/deployment/local-dev/ingress-deployment.yaml b/deployment/local-dev/ingress-deployment.yaml index 8b85b0246..efb543c34 100644 --- a/deployment/local-dev/ingress-deployment.yaml +++ b/deployment/local-dev/ingress-deployment.yaml @@ -1,12 +1,4 @@ apiVersion: v1 -kind: Namespace -metadata: - labels: - app.kubernetes.io/instance: ingress-nginx - app.kubernetes.io/name: ingress-nginx - name: ingress-nginx ---- -apiVersion: v1 automountServiceAccountToken: true kind: ServiceAccount metadata: @@ -17,7 +9,7 @@ metadata: app.kubernetes.io/part-of: ingress-nginx app.kubernetes.io/version: 1.8.0 name: ingress-nginx - namespace: ingress-nginx + namespace: languagedepot --- apiVersion: v1 kind: ServiceAccount @@ -29,7 +21,7 @@ metadata: app.kubernetes.io/part-of: ingress-nginx app.kubernetes.io/version: 1.8.0 name: ingress-nginx-admission - namespace: ingress-nginx + namespace: languagedepot --- apiVersion: rbac.authorization.k8s.io/v1 kind: Role @@ -41,7 +33,7 @@ metadata: app.kubernetes.io/part-of: ingress-nginx app.kubernetes.io/version: 1.8.0 name: ingress-nginx - namespace: ingress-nginx + namespace: languagedepot rules: - apiGroups: - "" @@ -131,7 +123,7 @@ metadata: app.kubernetes.io/part-of: ingress-nginx app.kubernetes.io/version: 1.8.0 name: ingress-nginx-admission - namespace: ingress-nginx + namespace: languagedepot rules: - apiGroups: - "" @@ -251,7 +243,7 @@ metadata: app.kubernetes.io/part-of: ingress-nginx app.kubernetes.io/version: 1.8.0 name: ingress-nginx - namespace: ingress-nginx + namespace: languagedepot roleRef: apiGroup: rbac.authorization.k8s.io kind: Role @@ -259,7 +251,7 @@ roleRef: subjects: - kind: ServiceAccount name: ingress-nginx - namespace: ingress-nginx + namespace: languagedepot --- apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding @@ -271,7 +263,7 @@ metadata: app.kubernetes.io/part-of: ingress-nginx app.kubernetes.io/version: 1.8.0 name: ingress-nginx-admission - namespace: ingress-nginx + namespace: languagedepot roleRef: apiGroup: rbac.authorization.k8s.io kind: Role @@ -279,7 +271,7 @@ roleRef: subjects: - kind: ServiceAccount name: ingress-nginx-admission - namespace: ingress-nginx + namespace: languagedepot --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding @@ -297,7 +289,7 @@ roleRef: subjects: - kind: ServiceAccount name: ingress-nginx - namespace: ingress-nginx + namespace: languagedepot --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding @@ -316,7 +308,7 @@ roleRef: subjects: - kind: ServiceAccount name: ingress-nginx-admission - namespace: ingress-nginx + namespace: languagedepot --- apiVersion: v1 data: @@ -334,7 +326,7 @@ metadata: app.kubernetes.io/part-of: ingress-nginx app.kubernetes.io/version: 1.8.0 name: ingress-nginx-controller - namespace: ingress-nginx + namespace: languagedepot --- apiVersion: v1 kind: Service @@ -346,7 +338,7 @@ metadata: app.kubernetes.io/part-of: ingress-nginx app.kubernetes.io/version: 1.8.0 name: ingress-nginx-controller - namespace: ingress-nginx + namespace: languagedepot spec: externalTrafficPolicy: Local ipFamilies: @@ -379,7 +371,7 @@ metadata: app.kubernetes.io/part-of: ingress-nginx app.kubernetes.io/version: 1.8.0 name: ingress-nginx-controller-admission - namespace: ingress-nginx + namespace: languagedepot spec: ports: - appProtocol: https @@ -402,7 +394,7 @@ metadata: app.kubernetes.io/part-of: ingress-nginx app.kubernetes.io/version: 1.8.0 name: ingress-nginx-controller - namespace: ingress-nginx + namespace: languagedepot spec: minReadySeconds: 0 revisionHistoryLimit: 10 @@ -516,7 +508,7 @@ metadata: app.kubernetes.io/part-of: ingress-nginx app.kubernetes.io/version: 1.8.0 name: ingress-nginx-admission-create - namespace: ingress-nginx + namespace: languagedepot spec: template: metadata: @@ -563,7 +555,7 @@ metadata: app.kubernetes.io/part-of: ingress-nginx app.kubernetes.io/version: 1.8.0 name: ingress-nginx-admission-patch - namespace: ingress-nginx + namespace: languagedepot spec: template: metadata: @@ -631,7 +623,7 @@ webhooks: clientConfig: service: name: ingress-nginx-controller-admission - namespace: ingress-nginx + namespace: languagedepot path: /networking/v1/ingresses failurePolicy: Fail matchPolicy: Equivalent diff --git a/deployment/base/namespace.yaml b/deployment/setup/namespace.yaml similarity index 100% rename from deployment/base/namespace.yaml rename to deployment/setup/namespace.yaml From e6061faa665a754bff096cdf29023e901ac5fa1b Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Tue, 21 May 2024 10:19:08 -0600 Subject: [PATCH 04/19] remove duplicate any translation key --- frontend/src/lib/i18n/locales/en.json | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/src/lib/i18n/locales/en.json b/frontend/src/lib/i18n/locales/en.json index a187e635d..df4791617 100644 --- a/frontend/src/lib/i18n/locales/en.json +++ b/frontend/src/lib/i18n/locales/en.json @@ -522,6 +522,5 @@ If you don't see a dialog or already closed it, click the button below:", "or": "Or", "any": "Any", "yes_no": "{value, select, true {Yes} false {No} other {Unknown}}", - "any": "Any", } } From ea236cd28f2054f5b0a6e8753790d7decc2dcfc6 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Thu, 16 May 2024 15:24:43 +0200 Subject: [PATCH 05/19] Try to ensure we're always using the right user/group --- deployment/base/hg-deployment.yaml | 1 + deployment/init-repos/hg-deployment-patch.yaml | 1 + 2 files changed, 2 insertions(+) diff --git a/deployment/base/hg-deployment.yaml b/deployment/base/hg-deployment.yaml index 8f2e1272b..027ce8f9b 100644 --- a/deployment/base/hg-deployment.yaml +++ b/deployment/base/hg-deployment.yaml @@ -176,6 +176,7 @@ spec: securityContext: runAsUser: 33 runAsGroup: 33 # www-data + runAsNonRoot: true image: busybox:1.36.1 command: - 'sh' diff --git a/deployment/init-repos/hg-deployment-patch.yaml b/deployment/init-repos/hg-deployment-patch.yaml index 82ac50d91..1fefc7d51 100644 --- a/deployment/init-repos/hg-deployment-patch.yaml +++ b/deployment/init-repos/hg-deployment-patch.yaml @@ -11,6 +11,7 @@ spec: securityContext: runAsUser: 33 runAsGroup: 33 # www-data + runAsNonRoot: true image: busybox:1.36.1 command: - 'sh' From d0f3381aaca78b50982e12867997d598c8456ecc Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Wed, 22 May 2024 17:46:47 -0600 Subject: [PATCH 06/19] attempt to speed up dotnet watch startup time when running in k8s --- backend/LexBoxApi/LexBoxApi.csproj | 2 +- backend/LexBoxApi/dev.Dockerfile | 14 +++++++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/backend/LexBoxApi/LexBoxApi.csproj b/backend/LexBoxApi/LexBoxApi.csproj index 60230d38b..583852189 100644 --- a/backend/LexBoxApi/LexBoxApi.csproj +++ b/backend/LexBoxApi/LexBoxApi.csproj @@ -58,6 +58,6 @@ - + diff --git a/backend/LexBoxApi/dev.Dockerfile b/backend/LexBoxApi/dev.Dockerfile index aad88da7b..a54f6395c 100644 --- a/backend/LexBoxApi/dev.Dockerfile +++ b/backend/LexBoxApi/dev.Dockerfile @@ -4,7 +4,7 @@ EXPOSE 80 EXPOSE 443 RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ --mount=type=cache,target=/var/lib/apt,sharing=locked \ - apt update && apt-get --no-install-recommends install -y rsync ssh + apt update && apt-get --no-install-recommends install -y tini RUN mkdir -p /var/www && chown -R www-data:www-data /var/www USER www-data:www-data WORKDIR /src/backend @@ -15,7 +15,15 @@ RUN for file in $(ls *.csproj); do dir=${file%.*} mkdir -p ${file%.*}/ && mv $fi COPY --chown=www-data . . WORKDIR /src/backend/LexBoxApi -RUN dotnet build #build and restore, should speed up watch run +#build here so that the build is run before container start, need to make sure the property is set both here +#and in the CMD command, otherwise it will rebuild every time the container starts +RUN dotnet build --property:InformationalVersion=dockerDev RUN mkdir /src/frontend + +#ensures the shutdown happens quickly +ENTRYPOINT ["tini", "--"] + # no need to restore because we already restored as part of building the image -CMD dotnet watch run -lp docker --property:InformationalVersion=dockerDev --no-hot-reload --no-restore +ENV ASPNETCORE_ENVIRONMENT=Development +ENV DOTNET_URLS=http://0.0.0.0:5158 +CMD dotnet watch --no-hot-reload run --property:InformationalVersion=dockerDev --no-restore From 62c170a4bcac9d955e6161284af2b3cb2a0f8fa1 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Thu, 23 May 2024 10:43:57 -0600 Subject: [PATCH 07/19] add health check for hgweb to match api version (#679) * use standard otel environment variables to configure service.version and if it's enabled or not * add a health check in dotnet to require hgweb be the same version * validate status code during deploy check * allow versions of hg container and api to not match in develop environment --- .github/workflows/deploy.yaml | 7 +++--- .idea/.idea.LexBox/.idea/indexLayout.xml | 1 + backend/LexBoxApi/LexBoxKernel.cs | 9 ++++--- backend/LexBoxApi/Services/HgService.cs | 7 ++++++ .../LexBoxApi/Services/HgWebHealthCheck.cs | 25 +++++++++++++++++++ backend/LexCore/Config/HgConfig.cs | 1 + .../LexCore/ServiceInterfaces/IHgService.cs | 1 + deployment/base/app-config.yaml | 2 +- deployment/base/hg-deployment.yaml | 4 +-- deployment/develop/app-config.yaml | 1 - .../develop/lexbox-deployment.patch.yaml | 2 ++ deployment/local-dev/hg-deployment-patch.yaml | 13 ---------- deployment/local-dev/kustomization.yaml | 1 - deployment/staging/app-config.yaml | 1 - hgweb/Dockerfile | 2 +- hgweb/command-runner.sh | 11 +++++++- hgweb/hg.conf | 1 + hgweb/hgweb.wsgi | 6 +---- hgweb/opentelemetry_module.conf | 2 -- skaffold.yaml | 2 ++ 20 files changed, 65 insertions(+), 34 deletions(-) create mode 100644 backend/LexBoxApi/Services/HgWebHealthCheck.cs delete mode 100644 deployment/local-dev/hg-deployment-patch.yaml diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 1a394cbba..b9b94a3cc 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -121,11 +121,12 @@ jobs: curl -s --head "$TARGET" > response.txt # get version from response, trim off the header and fix the line endings versionHeader=$((grep "lexbox-version" response.txt || echo VersionNotFound) | cut -d' ' -f 2 | tr -d '[:space:]') - if [[ "$versionHeader" == "$EXPECTED_VERSION" ]]; then - echo "Version is correct" + status_code=$(grep -oP "HTTP\/\d(\.\d)? \K\d+" response.txt) + if [[ "$versionHeader" == "$EXPECTED_VERSION" && "$status_code" == "200" ]]; then + echo "Version and status code are correct" exit 0 else - echo "Version '$versionHeader' is incorrect, expected '$EXPECTED_VERSION'" + echo "Health check failed, Version '$versionHeader', expected '$EXPECTED_VERSION', status code '$status_code'" n=$((n+1)) sleep $((DelayMultiplier * n)) fi diff --git a/.idea/.idea.LexBox/.idea/indexLayout.xml b/.idea/.idea.LexBox/.idea/indexLayout.xml index bfb0056af..3a934820b 100644 --- a/.idea/.idea.LexBox/.idea/indexLayout.xml +++ b/.idea/.idea.LexBox/.idea/indexLayout.xml @@ -2,6 +2,7 @@ + deployment frontend hasura hg-web diff --git a/backend/LexBoxApi/LexBoxKernel.cs b/backend/LexBoxApi/LexBoxKernel.cs index 4281d81b6..2d6569dba 100644 --- a/backend/LexBoxApi/LexBoxKernel.cs +++ b/backend/LexBoxApi/LexBoxKernel.cs @@ -6,6 +6,7 @@ using LexCore.Config; using LexCore.ServiceInterfaces; using LexSyncReverseProxy; +using Microsoft.Extensions.Diagnostics.HealthChecks; using Swashbuckle.AspNetCore.Swagger; namespace LexBoxApi; @@ -23,9 +24,9 @@ public static void AddLexBoxApi(this IServiceCollection services, .ValidateDataAnnotations() .ValidateOnStart(); // services.AddOptions() - // .BindConfiguration("HasuraConfig") - // .ValidateDataAnnotations() - // .ValidateOnStart(); + // .BindConfiguration("HasuraConfig") + // .ValidateDataAnnotations() + // .ValidateOnStart(); services.AddOptions() .BindConfiguration("CloudFlare") .ValidateDataAnnotations() @@ -53,6 +54,7 @@ public static void AddLexBoxApi(this IServiceCollection services, services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddTransient(); services.AddScoped(); services.AddScoped(); services.AddSingleton(); @@ -60,6 +62,7 @@ public static void AddLexBoxApi(this IServiceCollection services, if (environment.IsDevelopment()) services.AddHostedService(); services.AddScheduledTasks(configuration); + services.AddHealthChecks().AddCheck("hgweb", HealthStatus.Unhealthy, ["hg"], TimeSpan.FromSeconds(5)); services.AddSyncProxy(); AuthKernel.AddLexBoxAuth(services, configuration, environment); services.AddLexGraphQL(environment); diff --git a/backend/LexBoxApi/Services/HgService.cs b/backend/LexBoxApi/Services/HgService.cs index 15a0cfa77..7db803e4c 100644 --- a/backend/LexBoxApi/Services/HgService.cs +++ b/backend/LexBoxApi/Services/HgService.cs @@ -276,6 +276,13 @@ public async Task ExecuteHgRecover(string code, CancellationToken t return int.TryParse(str, out int result) ? result : null; } + public async Task HgCommandHealth() + { + var content = await ExecuteHgCommandServerCommand("health", "healthz", default); + var version = await content.ReadAsStringAsync(); + return version.Trim(); + } + private async Task ExecuteHgCommandServerCommand(string code, string command, CancellationToken token) { var httpClient = _hgClient.Value; diff --git a/backend/LexBoxApi/Services/HgWebHealthCheck.cs b/backend/LexBoxApi/Services/HgWebHealthCheck.cs new file mode 100644 index 000000000..14e97a737 --- /dev/null +++ b/backend/LexBoxApi/Services/HgWebHealthCheck.cs @@ -0,0 +1,25 @@ +using LexCore.Config; +using LexCore.ServiceInterfaces; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Options; + +namespace LexBoxApi.Services; + +public class HgWebHealthCheck(IHgService hgService, IOptions hgOptions) : IHealthCheck +{ + public async Task CheckHealthAsync(HealthCheckContext context, + CancellationToken cancellationToken = new()) + { + var version = await hgService.HgCommandHealth(); + if (string.IsNullOrEmpty(version)) + { + return HealthCheckResult.Unhealthy(); + } + if (hgOptions.Value.RequireContainerVersionMatch && version != AppVersionService.Version) + { + return HealthCheckResult.Degraded( + $"api version: '{AppVersionService.Version}' hg version: '{version}' mismatch"); + } + return HealthCheckResult.Healthy(); + } +} diff --git a/backend/LexCore/Config/HgConfig.cs b/backend/LexCore/Config/HgConfig.cs index 23f7061a7..843517769 100644 --- a/backend/LexCore/Config/HgConfig.cs +++ b/backend/LexCore/Config/HgConfig.cs @@ -16,4 +16,5 @@ public class HgConfig [Required, Url] public required string HgResumableUrl { get; init; } public bool AutoUpdateLexEntryCountOnSendReceive { get; init; } = false; + public bool RequireContainerVersionMatch { get; init; } = true; } diff --git a/backend/LexCore/ServiceInterfaces/IHgService.cs b/backend/LexCore/ServiceInterfaces/IHgService.cs index 01309eed9..a5ddadd26 100644 --- a/backend/LexCore/ServiceInterfaces/IHgService.cs +++ b/backend/LexCore/ServiceInterfaces/IHgService.cs @@ -19,4 +19,5 @@ public interface IHgService Task GetRepositoryIdentifier(Project project); Task ExecuteHgRecover(string code, CancellationToken token); bool HasAbandonedTransactions(string projectCode); + Task HgCommandHealth(); } diff --git a/deployment/base/app-config.yaml b/deployment/base/app-config.yaml index 311052657..2cdf08cde 100644 --- a/deployment/base/app-config.yaml +++ b/deployment/base/app-config.yaml @@ -4,6 +4,6 @@ metadata: name: app-config data: environment-name: "Development" - hg-otel-enabled: "ON" + hg-otel-disabled: "false" # "true" to disable OpenTelemetry hg-domain: "hg.localhost" diff --git a/deployment/base/hg-deployment.yaml b/deployment/base/hg-deployment.yaml index 027ce8f9b..2253cd61f 100644 --- a/deployment/base/hg-deployment.yaml +++ b/deployment/base/hg-deployment.yaml @@ -105,11 +105,11 @@ spec: value: ".*" - name: OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE value: ".*" - - name: OTEL_ENABLED + - name: OTEL_SDK_DISABLED valueFrom: configMapKeyRef: name: app-config - key: hg-otel-enabled + key: hg-otel-disabled - name: ENABLE_DEMAND_IMPORT value: "false" ports: diff --git a/deployment/develop/app-config.yaml b/deployment/develop/app-config.yaml index d8a68b7e2..f0e1b2f3c 100644 --- a/deployment/develop/app-config.yaml +++ b/deployment/develop/app-config.yaml @@ -4,6 +4,5 @@ metadata: name: app-config data: environment-name: "Development" - hg-otel-enabled: "ON" hg-domain: "hg-develop.lexbox.org" diff --git a/deployment/develop/lexbox-deployment.patch.yaml b/deployment/develop/lexbox-deployment.patch.yaml index e62ccdd99..58d016e9c 100644 --- a/deployment/develop/lexbox-deployment.patch.yaml +++ b/deployment/develop/lexbox-deployment.patch.yaml @@ -24,3 +24,5 @@ spec: value: "Language Depot (Develop) " - name: Email__BaseUrl value: "https://develop.lexbox.org" + - name: HgConfig__RequireContainerVersionMatch + value: "false" diff --git a/deployment/local-dev/hg-deployment-patch.yaml b/deployment/local-dev/hg-deployment-patch.yaml deleted file mode 100644 index 1009ae620..000000000 --- a/deployment/local-dev/hg-deployment-patch.yaml +++ /dev/null @@ -1,13 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: hg - namespace: languagedepot -spec: - template: - spec: - containers: - - name: hgweb - env: - - name: APP_VERSION - value: "dockerDev" diff --git a/deployment/local-dev/kustomization.yaml b/deployment/local-dev/kustomization.yaml index ef8b5c683..80a787681 100644 --- a/deployment/local-dev/kustomization.yaml +++ b/deployment/local-dev/kustomization.yaml @@ -30,7 +30,6 @@ patches: path: change-storage-class.patch.yaml - path: lexbox-deployment.patch.yaml - path: ui-deployment.patch.yaml - - path: hg-deployment-patch.yaml - path: hg-repos-pvc.patch.yaml - path: lexbox-service.patch.yaml - path: ingress-config.patch.yaml diff --git a/deployment/staging/app-config.yaml b/deployment/staging/app-config.yaml index efb735f7f..57a4435ea 100644 --- a/deployment/staging/app-config.yaml +++ b/deployment/staging/app-config.yaml @@ -4,5 +4,4 @@ metadata: name: app-config data: environment-name: "Staging" - hg-otel-enabled: "ON" hg-domain: "hg-staging.languageforge.org" diff --git a/hgweb/Dockerfile b/hgweb/Dockerfile index 73c4a5864..8aaea3599 100644 --- a/hgweb/Dockerfile +++ b/hgweb/Dockerfile @@ -48,4 +48,4 @@ VOLUME /var/hg/repos ARG APP_VERSION ENV APP_VERSION=$APP_VERSION -ENV OTEL_ENABLED=ON +ENV OTEL_RESOURCE_ATTRIBUTES="service.name=hgweb,service.version=${APP_VERSION}" diff --git a/hgweb/command-runner.sh b/hgweb/command-runner.sh index 49ef17e39..bc511e19e 100644 --- a/hgweb/command-runner.sh +++ b/hgweb/command-runner.sh @@ -1,7 +1,7 @@ #!/bin/bash # Define the list of allowed commands -allowed_commands=("verify" "tip" "wesaylexentrycount" "lexentrycount" "recover") +allowed_commands=("verify" "tip" "wesaylexentrycount" "lexentrycount" "recover" "healthz") # Get the project code and command name from the URL IFS='/' read -ra PATH_SEGMENTS <<< "$PATH_INFO" @@ -29,6 +29,15 @@ if [[ ! " ${allowed_commands[@]} " =~ " ${command_name} " ]]; then exit 1 fi +if [[ $command_name == "healthz" ]]; then + echo "lexbox-version: $APP_VERSION" + echo "Status: 200 OK" + echo "Content-type: text/plain" + echo "" + echo "$APP_VERSION" + exit 0 +fi + # Start outputting the result right away so the HTTP connection won't be timed out echo "Content-type: text/plain" echo "" diff --git a/hgweb/hg.conf b/hgweb/hg.conf index 23da62558..4c6030e2d 100644 --- a/hgweb/hg.conf +++ b/hgweb/hg.conf @@ -26,6 +26,7 @@ ServerName localhost + SetEnv APP_VERSION ${APP_VERSION} ScriptAliasMatch "^/command/(.*)" "/usr/local/www/commands/command-runner.sh/$1" Options +ExecCGI diff --git a/hgweb/hgweb.wsgi b/hgweb/hgweb.wsgi index 1941e46e6..a95415b9c 100644 --- a/hgweb/hgweb.wsgi +++ b/hgweb/hgweb.wsgi @@ -23,7 +23,6 @@ if os.getenv('ENABLE_DEMAND_IMPORT', 'false').lower() in ['1', 'true', 'yes']: else: demandimport.disable() -from opentelemetry.sdk.resources import SERVICE_NAME, Resource from opentelemetry import trace from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter from opentelemetry.sdk.trace import TracerProvider @@ -32,10 +31,7 @@ from opentelemetry.sdk.trace.export import ( ConsoleSpanExporter, ) -resource = Resource(attributes={ - SERVICE_NAME: "hgweb" -}) -provider = TracerProvider(resource=resource) +provider = TracerProvider() processor = BatchSpanProcessor(OTLPSpanExporter()) provider.add_span_processor(processor) diff --git a/hgweb/opentelemetry_module.conf b/hgweb/opentelemetry_module.conf index e27cf710b..63e858576 100644 --- a/hgweb/opentelemetry_module.conf +++ b/hgweb/opentelemetry_module.conf @@ -13,7 +13,6 @@ LoadFile /opt/opentelemetry-webserver-sdk/sdk_lib/lib/libopentelemetry_exporter_ LoadFile /opt/opentelemetry-webserver-sdk/sdk_lib/lib/libopentelemetry_webserver_sdk.so #Load the Apache Module. In this example for Apache LoadModule otel_apache_module /opt/opentelemetry-webserver-sdk/WebServerModule/Apache/libmod_apache_otel.so -ApacheModuleEnabled ${OTEL_ENABLED} #ApacheModule Otel Exporter details ApacheModuleOtelSpanExporter otlp @@ -37,7 +36,6 @@ ApacheModuleServiceInstanceId ${APP_VERSION} ApacheModuleResolveBackends ON #https://github.com/open-telemetry/opentelemetry-cpp-contrib/blob/2a0db982f3d7ee91dfbe8150435e49e837bfb7ce/instrumentation/otel-webserver-module/src/apache/ApacheTracing.cpp#L90 -ApacheModuleTraceAsError ${OTEL_ENABLED} #ApacheModuleWebserverContext DemoService DemoServiceNamespace DemoInstanceId #ApacheModuleSegmentType custom diff --git a/skaffold.yaml b/skaffold.yaml index 8904531fc..f8f9d27ae 100644 --- a/skaffold.yaml +++ b/skaffold.yaml @@ -30,6 +30,8 @@ build: context: hgweb docker: dockerfile: Dockerfile + buildArgs: + APP_VERSION: dockerDev local: useBuildkit: true concurrency: 2 From 5c401481ea39b0da8b306a43d2507f807110a7a7 Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Fri, 24 May 2024 08:44:25 +0700 Subject: [PATCH 08/19] Add "Create User" button to admin dashboard (#736) Admins now have a "Create User" button that pops up a modal dialog to create guest users (with the CreatedById set to the user ID of the admin who created their account). Also, the register page has been split into two pages: "register" and "accept email invitation". The register page now ignores any JWTs that might be presented, whereas accept-invite page uses the projects list from the JWT (if present) to add the new account to those projects. This avoids a possible scenario where two users share a computer, and one of them registered an account while the other one was logged in: the new account would have inherited the project list from the other person's account, even if that would have been inappropriate. --------- Co-authored-by: Tim Haasdyk --- .../LexBoxApi/Controllers/UserController.cs | 82 +++++++++++--- backend/LexBoxApi/GraphQL/ProjectMutations.cs | 21 +++- backend/LexBoxApi/GraphQL/UserMutations.cs | 60 ++++++++++ .../Models/Project/ProjectMemberInputs.cs | 2 +- backend/LexBoxApi/Services/EmailService.cs | 2 +- frontend/schema.graphql | 19 +++- frontend/src/lib/app.postcss | 14 ++- .../components/Projects/ProjectFilter.svelte | 4 +- .../lib/components/Users/CreateUser.svelte | 104 ++++++++++++++++++ .../components/Users/CreateUserModal.svelte | 41 +++++++ frontend/src/lib/gql/gql-client.ts | 4 +- frontend/src/lib/i18n/locales/en.json | 13 ++- frontend/src/lib/user.ts | 56 +++++++++- .../routes/(authenticated)/admin/+page.svelte | 16 +-- .../src/routes/(authenticated)/admin/+page.ts | 40 +++++++ .../BulkAddProjectMembers.svelte | 3 +- .../acceptInvitation/+page.svelte | 15 +++ .../(unauthenticated)/register/+page.svelte | 88 ++------------- frontend/tests/emailWorkflow.test.ts | 10 +- frontend/tests/pages/acceptInvitationPage.ts | 18 +++ 20 files changed, 481 insertions(+), 131 deletions(-) create mode 100644 frontend/src/lib/components/Users/CreateUser.svelte create mode 100644 frontend/src/lib/components/Users/CreateUserModal.svelte create mode 100644 frontend/src/routes/(unauthenticated)/acceptInvitation/+page.svelte create mode 100644 frontend/tests/pages/acceptInvitationPage.ts diff --git a/backend/LexBoxApi/Controllers/UserController.cs b/backend/LexBoxApi/Controllers/UserController.cs index 01fc14a95..92b6c7020 100644 --- a/backend/LexBoxApi/Controllers/UserController.cs +++ b/backend/LexBoxApi/Controllers/UserController.cs @@ -1,5 +1,6 @@ using System.Security.Cryptography; using LexBoxApi.Auth; +using LexBoxApi.Auth.Attributes; using LexBoxApi.Models; using LexBoxApi.Otel; using LexBoxApi.Services; @@ -65,27 +66,51 @@ public async Task> RegisterAccount(RegisterAccountInpu return ValidationProblem(ModelState); } - var jwtUser = _loggedInContext.MaybeUser; - var emailVerified = jwtUser?.Email == accountInput.Email; + var userEntity = CreateUserEntity(accountInput, emailVerified: false); + registerActivity?.AddTag("app.user.id", userEntity.Id); + _lexBoxDbContext.Users.Add(userEntity); + await _lexBoxDbContext.SaveChangesAsync(); - var salt = Convert.ToHexString(RandomNumberGenerator.GetBytes(SHA1.HashSizeInBytes)); - var userEntity = new User + var user = new LexAuthUser(userEntity); + await HttpContext.SignInAsync(user.GetPrincipal("Registration"), + new AuthenticationProperties { IsPersistent = true }); + + await _emailService.SendVerifyAddressEmail(userEntity); + return Ok(user); + } + + [HttpPost("acceptInvitation")] + [RequireAudience(LexboxAudience.RegisterAccount, true)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesErrorResponseType(typeof(Dictionary))] + [ProducesDefaultResponseType] + public async Task> AcceptEmailInvitation(RegisterAccountInput accountInput) + { + using var acceptActivity = LexBoxActivitySource.Get().StartActivity("AcceptInvitation"); + var validToken = await _turnstileService.IsTokenValid(accountInput.TurnstileToken, accountInput.Email); + acceptActivity?.AddTag("app.turnstile_token_valid", validToken); + if (!validToken) { - Id = Guid.NewGuid(), - Name = accountInput.Name, - Email = accountInput.Email, - LocalizationCode = accountInput.Locale, - Salt = salt, - PasswordHash = PasswordHashing.HashPassword(accountInput.PasswordHash, salt, true), - PasswordStrength = UserService.ClampPasswordStrength(accountInput.PasswordStrength), - IsAdmin = false, - EmailVerified = emailVerified, - Locked = false, - CanCreateProjects = false - }; - registerActivity?.AddTag("app.user.id", userEntity.Id); + ModelState.AddModelError(r => r.TurnstileToken, "token invalid"); + return ValidationProblem(ModelState); + } + + var jwtUser = _loggedInContext.User; + + var hasExistingUser = await _lexBoxDbContext.Users.FilterByEmailOrUsername(accountInput.Email).AnyAsync(); + acceptActivity?.AddTag("app.email_available", !hasExistingUser); + if (hasExistingUser) + { + ModelState.AddModelError(r => r.Email, "email already in use"); + return ValidationProblem(ModelState); + } + + var emailVerified = jwtUser.Email == accountInput.Email; + var userEntity = CreateUserEntity(accountInput, emailVerified); + acceptActivity?.AddTag("app.user.id", userEntity.Id); _lexBoxDbContext.Users.Add(userEntity); - if (jwtUser is not null && jwtUser.Projects.Length > 0) + // This audience check is redundant now because of [RequireAudience(LexboxAudience.RegisterAccount, true)], but let's leave it in for safety + if (jwtUser.Audience == LexboxAudience.RegisterAccount && jwtUser.Projects.Length > 0) { userEntity.Projects = jwtUser.Projects.Select(p => new ProjectUsers { Role = p.Role, ProjectId = p.ProjectId }).ToList(); } @@ -99,6 +124,27 @@ await HttpContext.SignInAsync(user.GetPrincipal("Registration"), return Ok(user); } + private User CreateUserEntity(RegisterAccountInput input, bool emailVerified, Guid? creatorId = null) + { + var salt = Convert.ToHexString(RandomNumberGenerator.GetBytes(SHA1.HashSizeInBytes)); + var userEntity = new User + { + Id = Guid.NewGuid(), + Name = input.Name, + Email = input.Email, + LocalizationCode = input.Locale, + Salt = salt, + PasswordHash = PasswordHashing.HashPassword(input.PasswordHash, salt, true), + PasswordStrength = UserService.ClampPasswordStrength(input.PasswordStrength), + IsAdmin = false, + EmailVerified = emailVerified, + CreatedById = creatorId, + Locked = false, + CanCreateProjects = false + }; + return userEntity; + } + [HttpPost("sendVerificationEmail")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] diff --git a/backend/LexBoxApi/GraphQL/ProjectMutations.cs b/backend/LexBoxApi/GraphQL/ProjectMutations.cs index 2aea2fe0f..9f4c87bd0 100644 --- a/backend/LexBoxApi/GraphQL/ProjectMutations.cs +++ b/backend/LexBoxApi/GraphQL/ProjectMutations.cs @@ -122,8 +122,11 @@ public async Task BulkAddProjectMembers( BulkAddProjectMembersInput input, LexBoxDbContext dbContext) { - var project = await dbContext.Projects.FindAsync(input.ProjectId); - if (project is null) throw new NotFoundException("Project not found", "project"); + if (input.ProjectId.HasValue) + { + var projectExists = await dbContext.Projects.AnyAsync(p => p.Id == input.ProjectId.Value); + if (!projectExists) throw new NotFoundException("Project not found", "project"); + } List AddedMembers = []; List CreatedMembers = []; List ExistingMembers = []; @@ -154,10 +157,13 @@ public async Task BulkAddProjectMembers( CanCreateProjects = false }; CreatedMembers.Add(new UserProjectRole(usernameOrEmail, input.Role)); - user.Projects.Add(new ProjectUsers { Role = input.Role, ProjectId = input.ProjectId, UserId = user.Id }); + if (input.ProjectId.HasValue) + { + user.Projects.Add(new ProjectUsers { Role = input.Role, ProjectId = input.ProjectId.Value, UserId = user.Id }); + } dbContext.Add(user); } - else + else if (input.ProjectId.HasValue) { var userProject = user.Projects.FirstOrDefault(p => p.ProjectId == input.ProjectId); if (userProject is not null) @@ -168,9 +174,14 @@ public async Task BulkAddProjectMembers( { AddedMembers.Add(new UserProjectRole(user.Username ?? user.Email!, input.Role)); // Not yet a member, so add a membership. We don't want to touch existing memberships, which might have other roles - user.Projects.Add(new ProjectUsers { Role = input.Role, ProjectId = input.ProjectId, UserId = user.Id }); + user.Projects.Add(new ProjectUsers { Role = input.Role, ProjectId = input.ProjectId.Value, UserId = user.Id }); } } + else + { + // No project ID specified, user already exists. This is probably part of bulk-adding through the admin dashboard or org page. + ExistingMembers.Add(new UserProjectRole(user.Username ?? user.Email!, ProjectRole.Unknown)); + } } await dbContext.SaveChangesAsync(); return new BulkAddProjectMembersResult(AddedMembers, CreatedMembers, ExistingMembers); diff --git a/backend/LexBoxApi/GraphQL/UserMutations.cs b/backend/LexBoxApi/GraphQL/UserMutations.cs index faea9fc8b..aff7f6108 100644 --- a/backend/LexBoxApi/GraphQL/UserMutations.cs +++ b/backend/LexBoxApi/GraphQL/UserMutations.cs @@ -1,14 +1,18 @@ using System.ComponentModel.DataAnnotations; +using System.Security.Cryptography; using LexBoxApi.Auth; using LexBoxApi.Auth.Attributes; using LexBoxApi.GraphQL.CustomTypes; using LexBoxApi.Models.Project; +using LexBoxApi.Otel; using LexBoxApi.Services; +using LexCore; using LexCore.Auth; using LexCore.Entities; using LexCore.Exceptions; using LexCore.ServiceInterfaces; using LexData; +using LexData.Entities; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.Tokens; @@ -23,6 +27,13 @@ public record ChangeUserAccountBySelfInput(Guid UserId, string? Email, string Na : ChangeUserAccountDataInput(UserId, Email, Name); public record ChangeUserAccountByAdminInput(Guid UserId, string? Email, string Name, UserRole Role) : ChangeUserAccountDataInput(UserId, Email, Name); + public record CreateGuestUserByAdminInput( + string? Email, + string Name, + string? Username, + string Locale, + string PasswordHash, + int PasswordStrength); [Error] [Error] @@ -63,6 +74,55 @@ EmailService emailService return UpdateUser(loggedInContext, permissionService, input, dbContext, emailService); } + [Error] + [Error] + [Error] + [Error] + [AdminRequired] + public async Task CreateGuestUserByAdmin( + LoggedInContext loggedInContext, + CreateGuestUserByAdminInput input, + LexBoxDbContext dbContext, + EmailService emailService + ) + { + using var createGuestUserActivity = LexBoxActivitySource.Get().StartActivity("CreateGuestUser"); + + var hasExistingUser = input.Email is null && input.Username is null + ? throw new RequiredException("Guest users must have either an email or a username") + : await dbContext.Users.FilterByEmailOrUsername(input.Email ?? input.Username!).AnyAsync(); + createGuestUserActivity?.AddTag("app.email_available", !hasExistingUser); + if (hasExistingUser) throw new UniqueValueException("Email"); + + var admin = loggedInContext.User; + + var salt = Convert.ToHexString(RandomNumberGenerator.GetBytes(SHA1.HashSizeInBytes)); + var userEntity = new User + { + Id = Guid.NewGuid(), + Name = input.Name, + Email = input.Email, + Username = input.Username, + LocalizationCode = input.Locale, + Salt = salt, + PasswordHash = PasswordHashing.HashPassword(input.PasswordHash, salt, true), + PasswordStrength = UserService.ClampPasswordStrength(input.PasswordStrength), + IsAdmin = false, + EmailVerified = false, + CreatedById = admin.Id, + Locked = false, + CanCreateProjects = false + }; + createGuestUserActivity?.AddTag("app.user.id", userEntity.Id); + dbContext.Users.Add(userEntity); + await dbContext.SaveChangesAsync(); + if (!string.IsNullOrEmpty(input.Email)) + { + await emailService.SendVerifyAddressEmail(userEntity); + } + return new LexAuthUser(userEntity); + } + private static async Task UpdateUser( LoggedInContext loggedInContext, IPermissionService permissionService, diff --git a/backend/LexBoxApi/Models/Project/ProjectMemberInputs.cs b/backend/LexBoxApi/Models/Project/ProjectMemberInputs.cs index 2bcdd5a19..13a787fba 100644 --- a/backend/LexBoxApi/Models/Project/ProjectMemberInputs.cs +++ b/backend/LexBoxApi/Models/Project/ProjectMemberInputs.cs @@ -5,6 +5,6 @@ namespace LexBoxApi.Models.Project; public record AddProjectMemberInput(Guid ProjectId, string UsernameOrEmail, ProjectRole Role); -public record BulkAddProjectMembersInput(Guid ProjectId, string[] Usernames, ProjectRole Role, string PasswordHash); +public record BulkAddProjectMembersInput(Guid? ProjectId, string[] Usernames, ProjectRole Role, string PasswordHash); public record ChangeProjectMemberRoleInput(Guid ProjectId, Guid UserId, ProjectRole Role); diff --git a/backend/LexBoxApi/Services/EmailService.cs b/backend/LexBoxApi/Services/EmailService.cs index b866dcd22..af8c46a93 100644 --- a/backend/LexBoxApi/Services/EmailService.cs +++ b/backend/LexBoxApi/Services/EmailService.cs @@ -127,7 +127,7 @@ public async Task SendCreateAccountEmail(string emailAddress, var httpContext = httpContextAccessor.HttpContext; ArgumentNullException.ThrowIfNull(httpContext); var queryString = QueryString.Create("email", emailAddress); - var returnTo = new UriBuilder() { Path = "/register", Query = queryString.Value }.Uri.PathAndQuery; + var returnTo = new UriBuilder() { Path = "/acceptInvitation", Query = queryString.Value }.Uri.PathAndQuery; var registerLink = _linkGenerator.GetUriByAction(httpContext, "LoginRedirect", "Login", diff --git a/frontend/schema.graphql b/frontend/schema.graphql index ad6c28db1..ce9aa959f 100644 --- a/frontend/schema.graphql +++ b/frontend/schema.graphql @@ -77,6 +77,11 @@ type CollectionSegmentInfo { hasPreviousPage: Boolean! } +type CreateGuestUserByAdminPayload { + lexAuthUser: LexAuthUser + errors: [CreateGuestUserByAdminError!] +} + type CreateOrganizationPayload { organization: Organization errors: [CreateOrganizationError!] @@ -185,6 +190,7 @@ type Mutation { softDeleteProject(input: SoftDeleteProjectInput!): SoftDeleteProjectPayload! changeUserAccountBySelf(input: ChangeUserAccountBySelfInput!): ChangeUserAccountBySelfPayload! changeUserAccountByAdmin(input: ChangeUserAccountByAdminInput!): ChangeUserAccountByAdminPayload! @authorize(policy: "AdminRequiredPolicy") + createGuestUserByAdmin(input: CreateGuestUserByAdminInput!): CreateGuestUserByAdminPayload! @authorize(policy: "AdminRequiredPolicy") deleteUserByAdminOrSelf(input: DeleteUserByAdminOrSelfInput!): DeleteUserByAdminOrSelfPayload! setUserLocked(input: SetUserLockedInput!): SetUserLockedPayload! @authorize(policy: "AdminRequiredPolicy") } @@ -363,6 +369,8 @@ union ChangeUserAccountByAdminError = NotFoundError | DbError | UniqueValueError union ChangeUserAccountBySelfError = NotFoundError | DbError | UniqueValueError +union CreateGuestUserByAdminError = NotFoundError | DbError | UniqueValueError | RequiredError + union CreateOrganizationError = DbError union CreateProjectError = DbError | AlreadyExistsError | ProjectCreatorsMustHaveEmail @@ -393,7 +401,7 @@ input BooleanOperationFilterInput { } input BulkAddProjectMembersInput { - projectId: UUID! + projectId: UUID usernames: [String!]! role: ProjectRole! passwordHash: String! @@ -429,6 +437,15 @@ input ChangeUserAccountBySelfInput { name: String! } +input CreateGuestUserByAdminInput { + email: String + name: String! + username: String + locale: String! + passwordHash: String! + passwordStrength: Int! +} + input CreateOrganizationInput { name: String! } diff --git a/frontend/src/lib/app.postcss b/frontend/src/lib/app.postcss index 1ff4078b7..7d170db1f 100644 --- a/frontend/src/lib/app.postcss +++ b/frontend/src/lib/app.postcss @@ -2,6 +2,18 @@ @tailwind components; @tailwind utilities; +@layer base { + :root { + --alert-link-color: #4100ff; + } + + @media (prefers-color-scheme: dark) { + :root { + --alert-link-color: #4dd0ff; + } + } +} + html, body, .drawer-side, @@ -152,7 +164,7 @@ input[readonly]:focus { } .alert a:not(.btn) { - color: #0024b9; + color: var(--alert-link-color, #0024b9); } .collapse input:hover ~ .collapse-title { diff --git a/frontend/src/lib/components/Projects/ProjectFilter.svelte b/frontend/src/lib/components/Projects/ProjectFilter.svelte index 04ff9b8c6..a208813e5 100644 --- a/frontend/src/lib/components/Projects/ProjectFilter.svelte +++ b/frontend/src/lib/components/Projects/ProjectFilter.svelte @@ -118,8 +118,8 @@ {:else}
- -
+ +
{$t('project.filter.select_user_from_table')} diff --git a/frontend/src/lib/components/Users/CreateUser.svelte b/frontend/src/lib/components/Users/CreateUser.svelte new file mode 100644 index 000000000..9009bc3e0 --- /dev/null +++ b/frontend/src/lib/components/Users/CreateUser.svelte @@ -0,0 +1,104 @@ + + + + + + + + + + {submitButtonText} + + + diff --git a/frontend/src/lib/components/Users/CreateUserModal.svelte b/frontend/src/lib/components/Users/CreateUserModal.svelte new file mode 100644 index 000000000..6c8e5fde4 --- /dev/null +++ b/frontend/src/lib/components/Users/CreateUserModal.svelte @@ -0,0 +1,41 @@ + + + +
+ +
+

{$t('common.did_you_know')}

+
+ + +
+
+
+

{$t('admin_dashboard.create_user_modal.create_user')}

+ createUserModal.submitModal()} + submitButtonText={$t('admin_dashboard.create_user_modal.create_user')} + /> +
diff --git a/frontend/src/lib/gql/gql-client.ts b/frontend/src/lib/gql/gql-client.ts index 1ac423236..124743f60 100644 --- a/frontend/src/lib/gql/gql-client.ts +++ b/frontend/src/lib/gql/gql-client.ts @@ -62,7 +62,9 @@ function createGqlClient(_gqlEndpoint?: string): Client { cache.invalidate({__typename: 'User', id: args.input.userId}); }, bulkAddProjectMembers: (result, args: BulkAddProjectMembersMutationVariables, cache, _info) => { - cache.invalidate({__typename: 'Project', id: args.input.projectId}); + if (args.input.projectId) { + cache.invalidate({__typename: 'Project', id: args.input.projectId}); + } }, leaveProject: (result, args: LeaveProjectMutationVariables, cache, _info) => { cache.invalidate({__typename: 'Project', id: args.input.projectId}); diff --git a/frontend/src/lib/i18n/locales/en.json b/frontend/src/lib/i18n/locales/en.json index df4791617..70a88d5a3 100644 --- a/frontend/src/lib/i18n/locales/en.json +++ b/frontend/src/lib/i18n/locales/en.json @@ -39,6 +39,11 @@ } } }, + "create_user_modal": { + "create_user": "Create User", + "help_create_single_guest_user": "You can invite users to register and join a project by themselves with the **Add Project Member** button. See [Add Project Member]({helpLink}) for details.", + "help_create_bulk_guest_users": "You can also create and add users to a project in bulk. Look for the **Bulk Add/Create Members** button or see [Bulk Add/Create Project Members]({helpLink}) for details.", + }, "user_details_modal": { "registered": "Registered", "locked": "Locked", @@ -220,7 +225,7 @@ the [Linguistics Institute at Payap University](https://li.payap.ac.th/) in Chia "shared_password_description": "For new users", "usernames": "Logins or emails (one per line)", "usernames_description": "This will be the **Send/Receive login** for new users", - "invalid_username": "Invalid login/username: {username}. Can only use letters, numbers, and underscore (_) characters.", + "invalid_username": "Invalid login/username: {username}. Only letters, numbers, and underscore (_) characters are allowed.", "empty_user_field": "Please enter email addresses and/or logins", "creator_must_have_email": "You must have an email address in order to create a project.", "members_added": "{addedCount} new {addedCount, plural, one {member was} other {members were}} added to project.", @@ -384,13 +389,18 @@ If you don't see a dialog or already closed it, click the button below:", "register": { "title": "Register", "account_exists": "An account with this email already exists", + "invalid_username": "Invalid login/username. Only letters, numbers, and underscore (_) characters are allowed.", "button_register": "Register", "label_email": "Email", + "label_email_or_username": "Email or login/username", "description_email": "This will be your **Send/Receive login**", "label_name": "Name", "label_password": "Password", "name_missing": "Name missing", }, + "accept_invitation": { + "title": "Accept Invitation", + }, "reset_password": { "title": "Reset Password", "new_password": "New Password", @@ -522,5 +532,6 @@ If you don't see a dialog or already closed it, click the button below:", "or": "Or", "any": "Any", "yes_no": "{value, select, true {Yes} false {No} other {Unknown}}", + "did_you_know": "Did you know?", } } diff --git a/frontend/src/lib/user.ts b/frontend/src/lib/user.ts index b18821510..c747938da 100644 --- a/frontend/src/lib/user.ts +++ b/frontend/src/lib/user.ts @@ -5,7 +5,8 @@ import { deleteCookie, getCookie } from './util/cookies' import {hash} from '$lib/util/hash'; import { ensureErrorIsTraced, errorSourceTag } from './otel' import zxcvbn from 'zxcvbn'; -import { type AuthUserProject, ProjectRole, UserRole } from './gql/types'; +import { type AuthUserProject, ProjectRole, UserRole, type CreateGuestUserByAdminInput } from './gql/types'; +import { _createGuestUserByAdmin } from '../routes/(authenticated)/admin/+page'; type LoginError = 'BadCredentials' | 'Locked'; type LoginResult = { @@ -18,6 +19,7 @@ type RegisterResponseErrors = { /* eslint-disable @typescript-eslint/naming-convention */ TurnstileToken?: unknown, Email?: unknown, + Required?: unknown, // RequiredException is thrown if GQL input is invalid, e.g. missing both email *and* username for CreateUser /* eslint-enable @typescript-eslint/naming-convention */ } } @@ -55,6 +57,8 @@ export type LexAuthUser = { export const USER_LOAD_KEY = 'user:current'; export const AUTH_COOKIE_NAME = '.LexBoxAuth'; +export const usernameRe = /^[a-zA-Z0-9_]+$/; + export function getHomePath(user: LexAuthUser | null): string { return user?.isAdmin ? '/admin' : '/'; } @@ -81,9 +85,9 @@ export async function login(userId: string, password: string): Promise { - const response = await fetch('/api/User/registerAccount', { +export type RegisterResponse = { error?: { turnstile?: boolean, accountExists?: boolean, invalidInput?: boolean }, user?: LexAuthUser }; +export async function createUser(endpoint: string, password: string, passwordStrength: number, name: string, email: string, locale: string, turnstileToken: string): Promise { + const response = await fetch(endpoint, { method: 'post', headers: { 'content-type': 'application/json', @@ -101,13 +105,55 @@ export async function register(password: string, passwordStrength: number, name: if (!response.ok) { const { errors } = await response.json() as RegisterResponseErrors; if (!errors) throw new Error('Missing error on non-ok response'); - return { error: { turnstile: 'TurnstileToken' in errors, accountExists: 'Email' in errors } }; + return { error: { turnstile: 'TurnstileToken' in errors, accountExists: 'Email' in errors, invalidInput: 'Required' in errors } }; } const responseJson = await response.json() as JwtTokenUser; const userJson: LexAuthUser = jwtToUser(responseJson); return { user: userJson }; } +export function register(password: string, passwordStrength: number, name: string, email: string, locale: string, turnstileToken: string): Promise { + return createUser('/api/User/registerAccount', password, passwordStrength, name, email, locale, turnstileToken); +} +export function acceptInvitation(password: string, passwordStrength: number, name: string, email: string, locale: string, turnstileToken: string): Promise { + return createUser('/api/User/acceptInvitation', password, passwordStrength, name, email, locale, turnstileToken); +} +export async function createGuestUserByAdmin(password: string, passwordStrength: number, name: string, email: string, locale: string, _turnstileToken: string): Promise { + const passwordHash = await hash(password); + const gqlInput: CreateGuestUserByAdminInput = { + passwordHash, + passwordStrength, + name, + locale, + }; + if (email.includes('@')) { + gqlInput.email = email; + } else { + gqlInput.username = email; + } + const gqlResponse = await _createGuestUserByAdmin(gqlInput); + if (gqlResponse.error?.byType('UniqueValueError')) { + return { error: { accountExists: true }}; + } + if (gqlResponse.error?.byType('RequiredError')) { + return { error: { invalidInput: true }}; + } + if (!gqlResponse.data?.createGuestUserByAdmin.lexAuthUser ) { + return { error: { invalidInput: true }}; + } + const responseUser = gqlResponse.data?.createGuestUserByAdmin.lexAuthUser; + const user: LexAuthUser = { + ...responseUser, + email: responseUser.email ?? undefined, + username: responseUser.username ?? undefined, + locked: responseUser.locked ?? false, + emailVerified: responseUser.emailVerificationRequired ?? false, + canCreateProjects: responseUser.canCreateProjects ?? false, + createdByAdmin: responseUser.createdByAdmin ?? false, + emailOrUsername: (responseUser.email ?? responseUser.username) as string, + } + return { user } +} export function getUser(cookies: Cookies): LexAuthUser | null { const token = getCookie(AUTH_COOKIE_NAME, cookies); diff --git a/frontend/src/routes/(authenticated)/admin/+page.svelte b/frontend/src/routes/(authenticated)/admin/+page.svelte index f5a02bc2c..5227fdc2b 100644 --- a/frontend/src/routes/(authenticated)/admin/+page.svelte +++ b/frontend/src/routes/(authenticated)/admin/+page.svelte @@ -21,8 +21,9 @@ import { Button } from '$lib/forms'; import { PageBreadcrumb } from '$lib/layout'; import AdminTabs, { type AdminTabId } from './AdminTabs.svelte'; + import { createGuestUserByAdmin } from '$lib/user'; + import CreateUserModal from '$lib/components/Users/CreateUserModal.svelte'; import type { Confidentiality } from '$lib/components/Projects'; - import { helpLinks } from '$lib/components/help'; export let data: PageData; $: projects = data.projects; @@ -70,6 +71,7 @@ } let userModal: UserModal; + let createUserModal: CreateUserModal; let deleteUserModal: DeleteUserModal; let formModal: EditUserAccount; @@ -125,14 +127,13 @@
- +
@@ -246,4 +247,5 @@ + diff --git a/frontend/src/routes/(authenticated)/admin/+page.ts b/frontend/src/routes/(authenticated)/admin/+page.ts index e68f8c052..5a2ea2f7f 100644 --- a/frontend/src/routes/(authenticated)/admin/+page.ts +++ b/frontend/src/routes/(authenticated)/admin/+page.ts @@ -9,6 +9,8 @@ import type { $OpResult, ChangeUserAccountByAdminInput, ChangeUserAccountByAdminMutation, + CreateGuestUserByAdminInput, + CreateGuestUserByAdminMutation, DraftProjectFilterInput, ProjectFilterInput, SetUserLockedInput, @@ -161,6 +163,44 @@ export async function _changeUserAccountByAdmin(input: ChangeUserAccountByAdminI return result; } +export async function _createGuestUserByAdmin(input: CreateGuestUserByAdminInput): $OpResult { + //language=GraphQL + const result = await getClient() + .mutation( + graphql(` + mutation CreateGuestUserByAdmin($input: CreateGuestUserByAdminInput!) { + createGuestUserByAdmin(input: $input) { + lexAuthUser { + id + name + email + username + role + isAdmin + locked + emailVerificationRequired + canCreateProjects + createdByAdmin + locale + projects { + projectId + role + } + } + errors { + __typename + ... on Error { + message + } + } + } + } + `), + { input: input } + ) + return result; +} + export async function _setUserLocked(input: SetUserLockedInput): $OpResult { //language=GraphQL const result = await getClient() diff --git a/frontend/src/routes/(authenticated)/project/[project_code]/BulkAddProjectMembers.svelte b/frontend/src/routes/(authenticated)/project/[project_code]/BulkAddProjectMembers.svelte index e35f85906..3355eb3ac 100644 --- a/frontend/src/routes/(authenticated)/project/[project_code]/BulkAddProjectMembers.svelte +++ b/frontend/src/routes/(authenticated)/project/[project_code]/BulkAddProjectMembers.svelte @@ -13,6 +13,7 @@ import { distinct } from '$lib/util/array'; import PasswordStrengthMeter from '$lib/components/PasswordStrengthMeter.svelte'; import { SupHelp, helpLinks } from '$lib/components/help'; + import { usernameRe } from '$lib/user'; enum BulkAddSteps { Add, @@ -35,8 +36,6 @@ let existingMembers: BulkAddProjectMembersResult['existingMembers'] = []; $: addedCount = addedMembers.length + createdMembers.length; - const usernameRe = /^[a-zA-Z0-9_]+$/; - function validateBulkAddInput(usernames: string[]): FormSubmitReturn { if (usernames.length === 0) return { usernamesText: [$t('project_page.bulk_add_members.empty_user_field')] }; diff --git a/frontend/src/routes/(unauthenticated)/acceptInvitation/+page.svelte b/frontend/src/routes/(unauthenticated)/acceptInvitation/+page.svelte new file mode 100644 index 000000000..dd8af1c28 --- /dev/null +++ b/frontend/src/routes/(unauthenticated)/acceptInvitation/+page.svelte @@ -0,0 +1,15 @@ + + + + + diff --git a/frontend/src/routes/(unauthenticated)/register/+page.svelte b/frontend/src/routes/(unauthenticated)/register/+page.svelte index 2ec6ba42a..8213ed750 100644 --- a/frontend/src/routes/(unauthenticated)/register/+page.svelte +++ b/frontend/src/routes/(unauthenticated)/register/+page.svelte @@ -1,89 +1,15 @@ - - - - - - - - {$t('register.button_register')} - + - - diff --git a/frontend/tests/emailWorkflow.test.ts b/frontend/tests/emailWorkflow.test.ts index 934aa4700..1661db416 100644 --- a/frontend/tests/emailWorkflow.test.ts +++ b/frontend/tests/emailWorkflow.test.ts @@ -4,7 +4,7 @@ import { deleteUser, getCurrentUserId, loginAs, logout } from './utils/authHelpe import { AdminDashboardPage } from './pages/adminDashboardPage'; import { EmailSubjects } from './pages/mailPages'; import { LoginPage } from './pages/loginPage'; -import { RegisterPage } from './pages/registerPage'; +import { AcceptInvitationPage } from './pages/acceptInvitationPage'; import { ResetPasswordPage } from './pages/resetPasswordPage'; import { UserAccountSettingsPage } from './pages/userAccountSettingsPage'; import { UserDashboardPage } from './pages/userDashboardPage'; @@ -134,7 +134,7 @@ test('register via new-user invitation email', async ({ page }) => { const emailPage = await inboxPage.openEmail(EmailSubjects.ProjectInvitation); const invitationUrl = await emailPage.getFirstLanguageDepotUrl(); expect(invitationUrl).not.toBeNull(); - expect(invitationUrl!).toContain('register'); + expect(invitationUrl!).toContain('acceptInvitation'); expect(invitationUrl!).toContain('returnTo='); expect(invitationUrl!).not.toContain('returnTo=http'); @@ -142,11 +142,11 @@ test('register via new-user invitation email', async ({ page }) => { const pagePromise = emailPage.page.context().waitForEvent('page'); await emailPage.clickFirstLanguageDepotUrl(); const newPage = await pagePromise; - const registerPage = await new RegisterPage(newPage).waitFor(); + const acceptPage = await new AcceptInvitationPage(newPage).waitFor(); await expect(newPage.getByLabel('Email')).toHaveValue(newEmail); - await registerPage.fillForm(`Test user ${uuid}`, newEmail, defaultPassword); + await acceptPage.fillForm(`Test user ${uuid}`, defaultPassword); - await registerPage.submit(); + await acceptPage.submit(); const userDashboardPage = await new UserDashboardPage(newPage).waitFor(); // Register current user ID to be cleaned up even if test fails later on diff --git a/frontend/tests/pages/acceptInvitationPage.ts b/frontend/tests/pages/acceptInvitationPage.ts new file mode 100644 index 000000000..f28f300bd --- /dev/null +++ b/frontend/tests/pages/acceptInvitationPage.ts @@ -0,0 +1,18 @@ +import type { Page } from '@playwright/test'; +import { BasePage } from './basePage'; + +export class AcceptInvitationPage extends BasePage { + constructor(page: Page) { + super(page, page.getByRole('heading', {name: 'Accept Invitation'}), `/acceptInvitation`); + } + + async fillForm(name: string, password: string, email?: string): Promise { + await this.page.getByLabel('Name').fill(name); + await this.page.getByLabel('Password').fill(password); + if (email) await this.page.getByLabel('Email').fill(email); + } + + async submit(): Promise { + await this.page.getByRole('button', {name: 'Register'}).click(); + } +} From 774f25376b29bfda3b3b9aa8ea92cc0c329a2b25 Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Mon, 27 May 2024 12:09:42 +0700 Subject: [PATCH 09/19] Add MaybeProtectedForm to optionally skip turnstile (#818) Using `` for this purpose caused a warning about an unknown `turnstileToken` property when the component was Form rather than ProtectedForm. To avoid this warning, we create a new component that selects between Form and ProtectedForm, and sends turnstileToken only to ProtectedForm. --- .../lib/components/Users/CreateUser.svelte | 6 +++--- .../src/lib/forms/MaybeProtectedForm.svelte | 19 +++++++++++++++++++ frontend/src/lib/forms/index.ts | 2 ++ 3 files changed, 24 insertions(+), 3 deletions(-) create mode 100644 frontend/src/lib/forms/MaybeProtectedForm.svelte diff --git a/frontend/src/lib/components/Users/CreateUser.svelte b/frontend/src/lib/components/Users/CreateUser.svelte index 9009bc3e0..f07b89155 100644 --- a/frontend/src/lib/components/Users/CreateUser.svelte +++ b/frontend/src/lib/components/Users/CreateUser.svelte @@ -1,6 +1,6 @@ - + diff --git a/frontend/viewer/src/lib/entry-editor/MultiFieldEditor.svelte b/frontend/viewer/src/lib/entry-editor/MultiFieldEditor.svelte index abb39fc6d..5014194e0 100644 --- a/frontend/viewer/src/lib/entry-editor/MultiFieldEditor.svelte +++ b/frontend/viewer/src/lib/entry-editor/MultiFieldEditor.svelte @@ -4,7 +4,7 @@ import type { Readable } from 'svelte/store'; import { createEventDispatcher, getContext } from 'svelte'; import type { MultiString, WritingSystems } from '../mini-lcm'; - import type { FieldConfig } from '../types'; + import type { FieldConfig, ViewConfig } from '../config-types'; import { pickWritingSystems } from '../utils'; const dispatch = createEventDispatcher<{ @@ -12,6 +12,7 @@ }>(); const allWritingSystems = getContext>('writingSystems'); + const viewConfig = getContext>('viewConfig'); type T = $$Generic<{}>; export let field: FieldConfig; @@ -35,12 +36,13 @@ label={collapse ? undefined : ws.abbreviation} labelPlacement={collapse ? undefined : 'left'} placeholder={collapse ? ws.abbreviation : undefined} + readonly={$viewConfig.readonly || field.readonly} /> {/each}
- diff --git a/frontend/viewer/src/lib/layout/EntryList.svelte b/frontend/viewer/src/lib/layout/EntryList.svelte new file mode 100644 index 000000000..cd8fa9b3e --- /dev/null +++ b/frontend/viewer/src/lib/layout/EntryList.svelte @@ -0,0 +1,111 @@ + + +
+
+ +
+ +
+ + +
+
+ {#if !entries || entries.length == 0} +
No entries found
+ {:else} + + {#each visibleItems as entry (entry.id)} +
+ {#if dictionaryMode} + + {:else} + selectEntry(entry)} + noShadow + /> + {/if} +
+ {/each} +
+ {/if} +
+
+ + diff --git a/frontend/viewer/src/lib/layout/FieldListDialog.svelte b/frontend/viewer/src/lib/layout/FieldListDialog.svelte new file mode 100644 index 000000000..39bfc604e --- /dev/null +++ b/frontend/viewer/src/lib/layout/FieldListDialog.svelte @@ -0,0 +1,58 @@ + + + +
+ +
+
+ {#each filteredFields as field} + + {:else} +
+ No matching fields +
+ {/each} +
+
+
actions
+
+ + diff --git a/frontend/viewer/src/lib/layout/IndexCharacters.svelte b/frontend/viewer/src/lib/layout/IndexCharacters.svelte new file mode 100644 index 000000000..876c4cb75 --- /dev/null +++ b/frontend/viewer/src/lib/layout/IndexCharacters.svelte @@ -0,0 +1,54 @@ + + + + +
+ {#each $characters ?? [] as character} + + + + {/each} + {#if $selectedCharacter} + + + + {/if} +
+
+ +
diff --git a/frontend/viewer/src/lib/layout/Toc.svelte b/frontend/viewer/src/lib/layout/Toc.svelte new file mode 100644 index 000000000..acfc8ee7d --- /dev/null +++ b/frontend/viewer/src/lib/layout/Toc.svelte @@ -0,0 +1,36 @@ + + +{#if entry} + {@const _headword = headword(entry) ?? ''} +
+ Entry:{_headword || '—'} + {#each entry.senses as sense, i (sense.id)} + {@const _sense = firstDefOrGlossVal(sense) ?? ''} + Sense:{_sense || '—'} + {#each sense.exampleSentences as example, j (example.id)} + {@const _example = firstSentenceOrTranslationVal(example) ?? ''} + Example:{_example || '—'} + {/each} + {/each} +
+{/if} + + diff --git a/frontend/viewer/src/lib/layout/ViewOptions.svelte b/frontend/viewer/src/lib/layout/ViewOptions.svelte new file mode 100644 index 000000000..77af46695 --- /dev/null +++ b/frontend/viewer/src/lib/layout/ViewOptions.svelte @@ -0,0 +1,58 @@ + + + +
+ ({ value: view, label: view.label }))} + bind:value={$viewConfig.activeView} + classes={{root: 'view-select w-auto'}} + clearable={false} + labelPlacement="top" + clearSearchOnOpen={false} + fieldActions={(elem) => /* a hack to disable typing/filtering */ {elem.readOnly = true; return [];}} + search={() => /* a hack to always show all options */ Promise.resolve()}> + + + + + +
+
+ Debug + + + + +
+
+
+ + diff --git a/frontend/viewer/src/lib/mini-lcm/query-options.ts b/frontend/viewer/src/lib/mini-lcm/query-options.ts index ba2fd60ba..b3f6255ee 100644 --- a/frontend/viewer/src/lib/mini-lcm/query-options.ts +++ b/frontend/viewer/src/lib/mini-lcm/query-options.ts @@ -4,7 +4,12 @@ */ export interface QueryOptions { - order: string; - count: number; - offset: number; + order: { + field: 'headword', + writingSystem: string | 'default', + ascending?: boolean + }; + count: number; + offset: number; + exemplar?: { value: string, writingSystem: string | 'default' }; } diff --git a/frontend/viewer/src/lib/search-bar/SearchBar.svelte b/frontend/viewer/src/lib/search-bar/SearchBar.svelte new file mode 100644 index 000000000..28a3aad14 --- /dev/null +++ b/frontend/viewer/src/lib/search-bar/SearchBar.svelte @@ -0,0 +1,92 @@ + + + (showSearchDialog = true)} + class="cursor-pointer opacity-80 hover:opacity-100"> + +
+ +
+
+ + +
+ +
+
+ {#each $displayedEntries as entry} + { + dispatch('entrySelected', entry); + showSearchDialog = false; + $search = undefined; + }} + /> + {/each} + {#if $displayedEntries.length === 0} +
+ {#if $result.search} + No entries found + {:else} + Search for an entry + {/if} +
+ {/if} + {#if $result.entries.length > $displayedEntries.length} +
+ {$result.entries.length - $displayedEntries.length} + {#if $result.entries.length === fetchCount}+{/if} + more matching entries... +
+ {/if} +
+
diff --git a/frontend/viewer/src/lib/services/lexbox-api.ts b/frontend/viewer/src/lib/services/lexbox-api.ts index 9a6a1e93f..ed78feeb7 100644 --- a/frontend/viewer/src/lib/services/lexbox-api.ts +++ b/frontend/viewer/src/lib/services/lexbox-api.ts @@ -1,13 +1,17 @@ -export type { IEntry, IExampleSentence, ISense, QueryOptions, WritingSystems } from '../mini-lcm'; -import type { IEntry, IExampleSentence, ISense, QueryOptions, WritingSystems } from '../mini-lcm'; +export type { IEntry, IExampleSentence, ISense, QueryOptions, WritingSystem, WritingSystems } from '../mini-lcm'; +import type {IEntry, IExampleSentence, ISense, QueryOptions, WritingSystem, WritingSystems} from '../mini-lcm'; import type { Operation } from 'fast-json-patch'; export type JsonPatch = Operation[]; +export enum WritingSystemType { + Vernacular = 0, + Analysis = 1, +} export interface LexboxApi { GetWritingSystems(): Promise; - GetExemplars(): Promise; + CreateWritingSystem(type: WritingSystemType, writingSystem: WritingSystem): Promise; + UpdateWritingSystem(wsId: string, type: WritingSystemType, update: JsonPatch): Promise; - GetEntriesForExemplar(exemplar: string, options: QueryOptions | undefined): Promise; GetEntries(options: QueryOptions | undefined): Promise; SearchEntries(query: string, options: QueryOptions | undefined): Promise; GetEntry(guid: string): Promise; diff --git a/frontend/viewer/src/lib/services/service-provider-signalr.ts b/frontend/viewer/src/lib/services/service-provider-signalr.ts index cf1df2c4d..04dbc9dd3 100644 --- a/frontend/viewer/src/lib/services/service-provider-signalr.ts +++ b/frontend/viewer/src/lib/services/service-provider-signalr.ts @@ -1,8 +1,8 @@ import type { HubConnection } from '@microsoft/signalr'; import {getHubProxyFactory, getReceiverRegister} from '../generated-signalr-client/TypedSignalR.Client'; import type { LexboxApi } from './lexbox-api'; -import {LexboxServiceProvider, LexboxServices} from './service-provider'; -import type { Entry } from '../generated-signalr-client/lexboxClientContracts'; +import {LexboxServices} from './service-provider'; +import type { Entry } from '../mini-lcm'; export function SetupSignalR(connection: HubConnection) { const hubFactory = getHubProxyFactory('ILexboxApiHub'); @@ -12,5 +12,5 @@ export function SetupSignalR(connection: HubConnection) { console.log('OnEntryUpdated', entry); } }); - LexboxServiceProvider.setService(LexboxServices.LexboxApi, hubProxy satisfies LexboxApi); -} \ No newline at end of file + window.lexbox.ServiceProvider.setService(LexboxServices.LexboxApi, hubProxy satisfies LexboxApi); +} diff --git a/frontend/viewer/src/lib/services/service-provider.ts b/frontend/viewer/src/lib/services/service-provider.ts index 592c8f03b..2e5b149f8 100644 --- a/frontend/viewer/src/lib/services/service-provider.ts +++ b/frontend/viewer/src/lib/services/service-provider.ts @@ -1,4 +1,5 @@ import {InMemoryApiService} from '../in-memory-api-service'; +import type {LexboxApi} from './lexbox-api'; declare global { @@ -21,13 +22,11 @@ export class LexboxServiceProvider { private services: Record = {}; public setService(key: string, service: unknown): void { - console.log('set-service', key, service); this.validateServiceKey(key); this.services[key] = service; } public getService(key: string): T { - console.log('get-service', key, this.services[key]); this.validateServiceKey(key); return this.services[key] as T; } @@ -38,6 +37,15 @@ export class LexboxServiceProvider { } } } -if (!window.lexbox) { window.lexbox = { ServiceProvider: new LexboxServiceProvider()}; } -else window.lexbox.ServiceProvider = new LexboxServiceProvider(); -window.lexbox.ServiceProvider.setService(LexboxServices.LexboxApi, new InMemoryApiService()); + +if (!window.lexbox) { + window.lexbox = {ServiceProvider: new LexboxServiceProvider()}; +} else window.lexbox.ServiceProvider = new LexboxServiceProvider(); + +export function useLexboxApi() { + let api = window.lexbox.ServiceProvider.getService(LexboxServices.LexboxApi); + if (!api) { + throw new Error('LexboxApi service not found'); + } + return api; +} diff --git a/frontend/viewer/src/lib/utils.ts b/frontend/viewer/src/lib/utils.ts index 318bb09de..bd6d6dd1f 100644 --- a/frontend/viewer/src/lib/utils.ts +++ b/frontend/viewer/src/lib/utils.ts @@ -1,11 +1,19 @@ -import type { IEntry, IMultiString, ISense, WritingSystem, WritingSystems } from './mini-lcm'; +import type { IEntry, IExampleSentence, IMultiString, ISense, WritingSystem, WritingSystems } from './mini-lcm'; -import type { WritingSystemSelection } from './types'; +import type { WritingSystemSelection } from './config-types'; export function firstVal(multi: IMultiString): string | undefined { return Object.values(multi).find(value => !!value); } +export function headword(entry: IEntry, ws?: string): string { + if (ws) { + return entry.citationForm[ws] ?? entry.lexemeForm[ws] ?? ''; + } else { + return firstVal(entry.citationForm) ?? firstVal(entry.lexemeForm) ?? ''; + } +} + export function firstDefOrGlossVal(sense: ISense | undefined): string { if (!sense) return ''; const definition = Object.values(sense.definition ?? {}).find(value => !!value); @@ -13,6 +21,13 @@ export function firstDefOrGlossVal(sense: ISense | undefined): string { return Object.values(sense.gloss ?? {}).find(value => !!value) ?? '' } +export function firstSentenceOrTranslationVal(example: IExampleSentence | undefined): string { + if (!example) return ''; + const sentence = Object.values(example.sentence ?? {}).find(value => !!value); + if (sentence) return sentence; + return Object.values(example.translation ?? {}).find(value => !!value) ?? '' +} + export function pickWritingSystems( ws: WritingSystemSelection, allWs: WritingSystems, @@ -32,3 +47,51 @@ export function pickWritingSystems( return allWs.analysis; } } + +export function filterEntries(entries: IEntry[], query: string) { + return entries.filter(entry => + [ + ...Object.values(entry.lexemeForm ?? {}), + ...Object.values(entry.citationForm ?? {}), + ...Object.values(entry.literalMeaning ?? {}), + ].some(value => value?.toLowerCase().includes(query.toLowerCase()))) +} + +const emptyIdPrefix = '00000000-0000-0000-0000-'; +export function emptyId(): string { + return emptyIdPrefix + crypto.randomUUID().slice(emptyIdPrefix.length); +} +export function isEmptyId(id: string): boolean { + return id.startsWith(emptyIdPrefix); +} + +export function defaultEntry(): IEntry { + return { + id: crypto.randomUUID(), + citationForm: {}, + lexemeForm: {}, + note: {}, + literalMeaning: {}, + senses: [] + }; +} + +export function defaultSense(): ISense { + return { + id: emptyId(), + definition: {}, + gloss: {}, + partOfSpeech: '', + semanticDomain: [], + exampleSentences: [] + }; +} + +export function defaultExampleSentence(): IExampleSentence { + return { + id: emptyId(), + sentence: {}, + translation: {}, + reference: '', + }; +} diff --git a/frontend/viewer/src/lib/utils/style-fix.ts b/frontend/viewer/src/lib/utils/style-fix.ts new file mode 100644 index 000000000..9d48df322 --- /dev/null +++ b/frontend/viewer/src/lib/utils/style-fix.ts @@ -0,0 +1,9 @@ +export function fixBrokenNestedGlobalStyles(shadowRoot: ShadowRoot) { + // for some reason a second occurence of :global() sticks around on the second part of some selectors + // e.g. the css has occurences of :is(.dark) :global(.dark\:text-yellow-200) { ... } + // presumably this is a bug in the svelte css transformer: https://github.com/sveltejs/svelte/blob/272ffc5520dfff0cc4605ecf45147ee660c87bb0/packages/svelte/src/compiler/phases/3-transform/css/index.js + // but I don't think this sort of super-edge-case bug is not going to get a lot of attention + shadowRoot?.querySelectorAll('style').forEach((style) => { + style.innerHTML = style.innerHTML.replace(/:global\(([^\(\)]+)\)/g, '$1'); + }); +} diff --git a/frontend/viewer/src/lib/utils/time.ts b/frontend/viewer/src/lib/utils/time.ts new file mode 100644 index 000000000..43c5272ba --- /dev/null +++ b/frontend/viewer/src/lib/utils/time.ts @@ -0,0 +1,79 @@ +import { writable, type Readable, derived } from 'svelte/store'; + +export const enum Duration { + Default = 5000, + Medium = 10000, + Long = 15000, +} + +export async function delay(ms: Duration | number = Duration.Default): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +const DEFAULT_DEBOUNCE_TIME = 400; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +interface Debouncer

{ + debounce: (...args: P) => void; + debouncing: Readable; + clear: () => void; +} + +function pickDebounceTime(debounce: number | boolean): number { + return typeof debounce === 'number' ? debounce : DEFAULT_DEBOUNCE_TIME; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function makeDebouncer

(fn: Debouncer

['debounce'], debounce: number | boolean = DEFAULT_DEBOUNCE_TIME): Debouncer

{ + const debouncing = writable(false); + + if (!debounce) { + return { debounce: fn, debouncing, clear: () => { } }; + } else { + const debounceTime = pickDebounceTime(debounce); + let timeout: ReturnType; + return { + debounce: (...args: P) => { + debouncing.set(true); + clearTimeout(timeout); + timeout = setTimeout(() => { + try { + fn(...args); + } finally { + debouncing.set(false); + } + }, debounceTime); + }, + debouncing, + clear: () => { + clearTimeout(timeout); + debouncing.set(false); + }, + }; + } +} + +/** + * @param fn A function that maps the store value to an async result + * @returns A store that contains the result of the async function, optionally debounced + */ +export function deriveAsync( + store: Readable, + fn: (value: T) => Promise, + initialValue?: D, + debounce: number | boolean = false): Readable { + + const debounceTime = pickDebounceTime(debounce); + let timeout: ReturnType | undefined; + + return derived(store, (value, set) => { + clearTimeout(timeout); + timeout = setTimeout(() => { + const myTimeout = timeout; + void fn(value).then((result) => { + if (myTimeout !== timeout) return; // discard outdated results + set(result); + }); + }, debounceTime); + }, initialValue); +} diff --git a/frontend/viewer/src/main.ts b/frontend/viewer/src/main.ts index 1cd942364..0b6ab09d5 100644 --- a/frontend/viewer/src/main.ts +++ b/frontend/viewer/src/main.ts @@ -2,7 +2,7 @@ import './app.postcss'; -import App from './App.svelte' +import App from './App.svelte'; new App({ target: document.getElementById('app')!, @@ -13,13 +13,3 @@ new App({ import './web-component'; //*/ - -// import { HubConnectionBuilder } from '@microsoft/signalr'; -// import { SetupSignalR } from './lib/services/service-provider-signalr'; - -// const connection = new HubConnectionBuilder() -// .withUrl("/api/hub/project") -// .withAutomaticReconnect() -// .build(); -// await connection.start(); -// SetupSignalR(connection); diff --git a/frontend/viewer/tailwind.config.cjs b/frontend/viewer/tailwind.config.cjs index ef5499e31..32643c388 100644 --- a/frontend/viewer/tailwind.config.cjs +++ b/frontend/viewer/tailwind.config.cjs @@ -21,8 +21,8 @@ module.exports = { "light": { "color-scheme": "light", "--base-text": "#394E6A", - "primary": "#d1c1d7", - "secondary": "#f6cbd1", + "primary": "#0050CC", + "secondary": "#A8C8FF", "accent": "#b4e9d6", "neutral": "#70acc7", "surface-100": "oklch(100% 0 0)", @@ -36,7 +36,7 @@ module.exports = { "dark": { "color-scheme": "dark", "--base-text": "#e6e6e6", - "primary": "oklch(100% 0 0)", + "primary": "#4882D0", "secondary": "#152747", "accent": "#513448", "neutral": "#331800", @@ -51,5 +51,15 @@ module.exports = { "danger": "#ff6f6f", } } - } + }, + theme: { + extend: { + screens: { + 'max-xs': { 'max': '400px' }, + 'max-sm': { 'max': '639px' }, + 'max-md': { 'max': '767px' }, + 'max-lg': { 'max': '1023px' }, + }, + }, + }, }; diff --git a/frontend/viewer/vite.config.ts b/frontend/viewer/vite.config.ts index dc9c76b4a..7ca8a8911 100644 --- a/frontend/viewer/vite.config.ts +++ b/frontend/viewer/vite.config.ts @@ -3,37 +3,50 @@ import { svelte } from '@sveltejs/vite-plugin-svelte' // https://vitejs.dev/config/ export default defineConfig(({ mode }) => { - if (mode === 'web-component') { - return { - build: { + const webComponent = mode === 'web-component'; + return { + build: { + ...(webComponent ? { lib: { entry: 'src/web-component.ts', formats: ['es'], }, outDir: 'dist-web-component', - }, - plugins: [svelte({ - compilerOptions: { - customElement: true, + } : {}), + chunkSizeWarningLimit: 1000, + rollupOptions: { + output: { + manualChunks: { + 'svelte-ux': ['svelte-ux'], + } }, - })], - } - } - - return { + onwarn: (warning, handler) => { + // we don't have control over these warnings + if (warning.code === 'INVALID_ANNOTATION' && warning.message.includes('node_modules/@microsoft/signalr')) return; + handler(warning); + } + }, + }, plugins: [svelte({ compilerOptions: { customElement: true, }, + onwarn: (warning, handler) => { + // we don't have control over these warnings and there are lots + if (warning.filename?.includes('node_modules/svelte-ux')) return; + handler(warning); + }, })], - server: { - proxy: { - '/api': { - target: 'https://localhost:7211', - secure: false, - ws: true + ...(!webComponent ? { + server: { + proxy: { + '/api': { + target: 'http://localhost:5137', + secure: false, + ws: true + } } } - } + } : {}), } }); From 1ce357ac1087a21123f9e6c657bf8c9c81410ed3 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Tue, 28 May 2024 17:57:34 +0200 Subject: [PATCH 15/19] Set default LF classic mongo creds for dev environments (#828) --- backend/LexBoxApi/appsettings.Development.json | 3 +++ deployment/local-dev/kustomization.yaml | 1 + deployment/local-dev/lf-classic-secrets.yaml | 11 +++++++++++ 3 files changed, 15 insertions(+) create mode 100644 deployment/local-dev/lf-classic-secrets.yaml diff --git a/backend/LexBoxApi/appsettings.Development.json b/backend/LexBoxApi/appsettings.Development.json index 154f63fdb..ec867ac9d 100644 --- a/backend/LexBoxApi/appsettings.Development.json +++ b/backend/LexBoxApi/appsettings.Development.json @@ -11,6 +11,9 @@ }, "LfClassicConfig": { "ConnectionString": "mongodb://localhost:27017", + "AuthSource": "admin", + "Username": "admin", + "Password": "pass" }, "ForwardedHeadersOptions": { "KnownNetworks": [ diff --git a/deployment/local-dev/kustomization.yaml b/deployment/local-dev/kustomization.yaml index 80a787681..259f675b8 100644 --- a/deployment/local-dev/kustomization.yaml +++ b/deployment/local-dev/kustomization.yaml @@ -7,6 +7,7 @@ resources: #- secrets.yaml - ingress-deployment.yaml - db-secrets.yaml +- lf-classic-secrets.yaml components: - ../init-repos diff --git a/deployment/local-dev/lf-classic-secrets.yaml b/deployment/local-dev/lf-classic-secrets.yaml new file mode 100644 index 000000000..b2f628a9e --- /dev/null +++ b/deployment/local-dev/lf-classic-secrets.yaml @@ -0,0 +1,11 @@ +# https://kubernetes.io/docs/concepts/configuration/secret + +apiVersion: v1 +kind: Secret +metadata: + name: lf-mongo-auth + namespace: languagedepot +stringData: + MONGODB_AUTHSOURCE: 'admin' + MONGODB_USER: 'admin' + MONGODB_PASS: 'pass' From e3d7d166e61bd9100dc1b623891360ec8cc72373 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Tue, 28 May 2024 10:33:47 -0600 Subject: [PATCH 16/19] configure openid connect (#809) * attempt to add openid connect support * update dotnet ef tool version * disable openId in production using a config option. This lets us figure out how to we want to do key storage in production * fix redirect loop trying to send the user to the login page, if that's handled by aspnet it'll result in a redirect loop * trim down claims check list since most aren't used, allow name claim to end up in ID token for testing purposes * correct vite proxy to send traces to the collector and not dotnet. * configure the login page to handle return urls * allow application manager to be null in seeding data to allow for case where openId is disabled * require pkce and disable implicit flow, enable oauth to work over http and with proper CORS headers * create approval flow for oauth login * correct vite proxy so https isn't used incorrectly when the backend redirects somewhere * pass redirect uri along via google login, fix bug where redirect url was always null in `CompleteGoogleLogin` due to using the wrong property. * Redesign authorize page * extract oauth code out of LoginController.cs and into OauthController.cs, revert some changes made to vite.config.ts --------- Co-authored-by: Tim Haasdyk --- .config/dotnet-tools.json | 2 +- .idea/.idea.LexBox/.idea/.gitignore | 2 + .idea/.idea.LexBox/.idea/dataSources.xml | 57 +- .idea/.idea.LexBox/.idea/indexLayout.xml | 2 +- .idea/.idea.LexBox/.idea/vcs.xml | 3 +- backend/LexBoxApi/Auth/AuthKernel.cs | 79 +- backend/LexBoxApi/Auth/OpenIdOptions.cs | 9 + backend/LexBoxApi/Auth/ScopeRequestFixer.cs | 28 + .../LexBoxApi/Controllers/LoginController.cs | 24 +- .../LexBoxApi/Controllers/OauthController.cs | 307 ++++ backend/LexBoxApi/LexBoxApi.csproj | 4 + backend/LexBoxApi/Program.cs | 4 + .../LexBoxApi/appsettings.Development.json | 5 +- backend/LexBoxApi/appsettings.json | 3 + backend/LexData/DataKernel.cs | 1 + backend/LexData/LexData.csproj | 7 +- .../20240515164328_AddOpenId.Designer.cs | 1271 +++++++++++++++++ .../Migrations/20240515164328_AddOpenId.cs | 166 +++ .../LexBoxDbContextModelSnapshot.cs | 246 +++- backend/LexData/SeedingData.cs | 44 +- .../Fixtures/TestingServicesFixture.cs | 2 +- backend/Testing/Testing.csproj | 1 - .../components/SigninWithGoogleButton.svelte | 2 +- frontend/src/lib/icons/Icon.svelte | 2 +- frontend/src/lib/layout/Layout.svelte | 56 +- .../(authenticated)/authorize/+layout@.svelte | 7 + .../(authenticated)/authorize/+page.svelte | 73 + .../routes/(authenticated)/authorize/+page.ts | 9 + .../(unauthenticated)/login/+page.svelte | 57 +- frontend/vite.config.ts | 21 +- 30 files changed, 2392 insertions(+), 102 deletions(-) create mode 100644 backend/LexBoxApi/Auth/OpenIdOptions.cs create mode 100644 backend/LexBoxApi/Auth/ScopeRequestFixer.cs create mode 100644 backend/LexBoxApi/Controllers/OauthController.cs create mode 100644 backend/LexData/Migrations/20240515164328_AddOpenId.Designer.cs create mode 100644 backend/LexData/Migrations/20240515164328_AddOpenId.cs create mode 100644 frontend/src/routes/(authenticated)/authorize/+layout@.svelte create mode 100644 frontend/src/routes/(authenticated)/authorize/+page.svelte create mode 100644 frontend/src/routes/(authenticated)/authorize/+page.ts diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 558293e1d..93ec8eb6c 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "dotnet-ef": { - "version": "7.0.10", + "version": "8.0.5", "commands": [ "dotnet-ef" ] diff --git a/.idea/.idea.LexBox/.idea/.gitignore b/.idea/.idea.LexBox/.idea/.gitignore index 4ec73edd9..daa11e94f 100644 --- a/.idea/.idea.LexBox/.idea/.gitignore +++ b/.idea/.idea.LexBox/.idea/.gitignore @@ -11,3 +11,5 @@ # Datasource local storage ignored files /dataSources/ /dataSources.local.xml +# GitHub Copilot persisted chat sessions +/copilot/chatSessions diff --git a/.idea/.idea.LexBox/.idea/dataSources.xml b/.idea/.idea.LexBox/.idea/dataSources.xml index eceae86ec..b6ad8028d 100644 --- a/.idea/.idea.LexBox/.idea/dataSources.xml +++ b/.idea/.idea.LexBox/.idea/dataSources.xml @@ -5,32 +5,17 @@ postgresql true org.postgresql.Driver - jdbc:postgresql://localhost/lexbox + jdbc:postgresql://localhost:5433/lexbox - + - $ProjectFileDir$ - - mariadb - true - redmine data - org.mariadb.jdbc.Driver - jdbc:mariadb://localhost:3306 - - - - - - - $ProjectFileDir$ - postgresql true @@ -65,5 +50,41 @@ $ProjectFileDir$ + + mongo + true + true + com.dbschema.MongoJdbcDriver + mongodb://localhost:27018 + $ProjectFileDir$ + + + mongo + true + true + com.dbschema.MongoJdbcDriver + mongodb://localhost:27017 + + + + + + $ProjectFileDir$ + + + sqlite.xerial + true + org.sqlite.JDBC + jdbc:sqlite:C:\dev\LexBox\backend\LocalWebApp\sena-3.sqlite + + + + $ProjectFileDir$ + + + file://$APPLICATION_CONFIG_DIR$/jdbc-drivers/Xerial SQLiteJDBC/3.43.0/org/xerial/sqlite-jdbc/3.43.0.0/sqlite-jdbc-3.43.0.0.jar + + + - + \ No newline at end of file diff --git a/.idea/.idea.LexBox/.idea/indexLayout.xml b/.idea/.idea.LexBox/.idea/indexLayout.xml index 3a934820b..24b63bcb7 100644 --- a/.idea/.idea.LexBox/.idea/indexLayout.xml +++ b/.idea/.idea.LexBox/.idea/indexLayout.xml @@ -20,4 +20,4 @@ frontend/.svelte-kit/output - \ No newline at end of file + diff --git a/.idea/.idea.LexBox/.idea/vcs.xml b/.idea/.idea.LexBox/.idea/vcs.xml index 30edf8684..9041d4ebe 100644 --- a/.idea/.idea.LexBox/.idea/vcs.xml +++ b/.idea/.idea.LexBox/.idea/vcs.xml @@ -12,5 +12,6 @@ + - \ No newline at end of file + diff --git a/backend/LexBoxApi/Auth/AuthKernel.cs b/backend/LexBoxApi/Auth/AuthKernel.cs index 892deaa5c..38fae368b 100644 --- a/backend/LexBoxApi/Auth/AuthKernel.cs +++ b/backend/LexBoxApi/Auth/AuthKernel.cs @@ -1,3 +1,4 @@ +using System.Collections.ObjectModel; using System.Diagnostics.CodeAnalysis; using System.Net.Http.Headers; using System.Text; @@ -5,12 +6,14 @@ using LexBoxApi.Auth.Requirements; using LexBoxApi.Controllers; using LexCore.Auth; +using LexData; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authorization; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Logging; using Microsoft.OpenApi.Models; +using OpenIddict.Validation.AspNetCore; namespace LexBoxApi.Auth; @@ -66,6 +69,8 @@ public static void AddLexBoxAuth(IServiceCollection services, .BindConfiguration("Authentication:Jwt") .ValidateDataAnnotations() .ValidateOnStart(); + services.AddOptions() + .BindConfiguration("Authentication:OpenId"); services.AddAuthentication(options => { options.DefaultScheme = DefaultScheme; @@ -78,7 +83,13 @@ public static void AddLexBoxAuth(IServiceCollection services, { options.ForwardDefaultSelector = context => { - + if (context.Request.Headers.ContainsKey("Authorization") && + context.Request.Headers.Authorization.ToString().StartsWith("Bearer") && + context.RequestServices.GetService>()?.Value.Enable == true) + { + //fow now this will use oauth + return OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme; + } if (context.Request.IsJwtRequest()) { return JwtBearerDefaults.AuthenticationScheme; @@ -101,9 +112,9 @@ public static void AddLexBoxAuth(IServiceCollection services, .AddCookie(options => { configuration.Bind("Authentication:Cookie", options); - options.LoginPath = "/api/login"; + options.LoginPath = "/login"; options.Cookie.Name = AuthCookieName; - options.ForwardChallenge = JwtBearerDefaults.AuthenticationScheme; + // options.ForwardChallenge = JwtBearerDefaults.AuthenticationScheme; options.ForwardForbid = JwtBearerDefaults.AuthenticationScheme; }) .AddJwtBearer(options => @@ -152,7 +163,8 @@ public static void AddLexBoxAuth(IServiceCollection services, context.HandleResponse(); var loginController = context.HttpContext.RequestServices.GetRequiredService(); loginController.ControllerContext.HttpContext = context.HttpContext; - var redirectTo = await loginController.CompleteGoogleLogin(context.Principal, context.Properties?.RedirectUri); + //using context.ReturnUri and not context.Properties.RedirectUri because the latter is null + var redirectTo = await loginController.CompleteGoogleLogin(context.Principal, context.ReturnUri); context.HttpContext.Response.Redirect(redirectTo); }; }); @@ -185,6 +197,65 @@ public static void AddLexBoxAuth(IServiceCollection services, } }); }); + + var openIdOptions = configuration.GetSection("Authentication:OpenId").Get(); + if (openIdOptions?.Enable == true) AddOpenId(services, environment); + } + + private static void AddOpenId(IServiceCollection services, IWebHostEnvironment environment) + { + services.Add(ScopeRequestFixer.Descriptor.ServiceDescriptor); + //openid server + services.AddOpenIddict() + .AddCore(options => + { + options.UseEntityFrameworkCore().UseDbContext(); + options.UseQuartz(); + }) + .AddServer(options => + { + options.RegisterScopes("openid", "profile", "email"); + //todo add application claims + options.RegisterClaims("aud", "email", "exp", "iss", "iat", "sub", "name"); + options.SetAuthorizationEndpointUris("api/oauth/open-id-auth"); + options.SetTokenEndpointUris("api/oauth/token"); + options.SetIntrospectionEndpointUris("api/oauth/introspect"); + options.SetUserinfoEndpointUris("api/oauth/userinfo"); + options.Configure(serverOptions => serverOptions.Handlers.Add(ScopeRequestFixer.Descriptor)); + + options.AllowAuthorizationCodeFlow() + .AllowRefreshTokenFlow(); + + options.RequireProofKeyForCodeExchange();//best practice to use PKCE with auth code flow and no implicit flow + + options.IgnoreResponseTypePermissions(); + options.IgnoreScopePermissions(); + if (environment.IsDevelopment()) + { + options.AddDevelopmentEncryptionCertificate(); + options.AddDevelopmentSigningCertificate(); + } + else + { + //see docs: https://documentation.openiddict.com/configuration/encryption-and-signing-credentials.html + throw new NotImplementedException("need to implement loading keys from a file"); + } + + var aspnetCoreBuilder = options.UseAspNetCore() + .EnableAuthorizationEndpointPassthrough() + .EnableTokenEndpointPassthrough(); + if (environment.IsDevelopment()) + { + aspnetCoreBuilder.DisableTransportSecurityRequirement(); + } + }) + .AddValidation(options => + { + options.UseLocalServer(); + options.UseAspNetCore(); + options.AddAudiences(Enum.GetValues().Where(a => a != LexboxAudience.Unknown).Select(a => a.ToString()).ToArray()); + options.EnableAuthorizationEntryValidation(); + }); } public static AuthorizationPolicyBuilder RequireDefaultLexboxAuth(this AuthorizationPolicyBuilder builder) diff --git a/backend/LexBoxApi/Auth/OpenIdOptions.cs b/backend/LexBoxApi/Auth/OpenIdOptions.cs new file mode 100644 index 000000000..eeef9e5e2 --- /dev/null +++ b/backend/LexBoxApi/Auth/OpenIdOptions.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; + +namespace LexBoxApi.Auth; + +public class OpenIdOptions +{ + [Required] + public required bool Enable { get; set; } +} diff --git a/backend/LexBoxApi/Auth/ScopeRequestFixer.cs b/backend/LexBoxApi/Auth/ScopeRequestFixer.cs new file mode 100644 index 000000000..9048091ca --- /dev/null +++ b/backend/LexBoxApi/Auth/ScopeRequestFixer.cs @@ -0,0 +1,28 @@ +using OpenIddict.Abstractions; +using OpenIddict.Server; + +namespace LexBoxApi.Auth; + +///

+/// the MSAL library makes requests with the scope parameter, which is invalid, this attempts to remove the scope before it's rejected +/// +public sealed class ScopeRequestFixer : IOpenIddictServerHandler +{ + public static OpenIddictServerHandlerDescriptor Descriptor { get; } + = OpenIddictServerHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(OpenIddictServerHandlers.Exchange.ValidateResourceOwnerCredentialsParameters.Descriptor.Order + 1) + .SetType(OpenIddictServerHandlerType.Custom) + .Build(); + + public ValueTask HandleAsync(OpenIddictServerEvents.ValidateTokenRequestContext context) + { + if (!string.IsNullOrEmpty(context.Request.Scope) && (context.Request.IsAuthorizationCodeGrantType() || + context.Request.IsDeviceCodeGrantType())) + { + context.Request.Scope = null; + } + + return default; + } +} diff --git a/backend/LexBoxApi/Controllers/LoginController.cs b/backend/LexBoxApi/Controllers/LoginController.cs index 944ead716..34466ab10 100644 --- a/backend/LexBoxApi/Controllers/LoginController.cs +++ b/backend/LexBoxApi/Controllers/LoginController.cs @@ -8,13 +8,9 @@ using LexCore.Auth; using LexData; using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ModelBinding; -using Microsoft.EntityFrameworkCore; -using Microsoft.AspNetCore.Http; -using LexCore.Entities; using System.Security.Claims; using Microsoft.AspNetCore.Authentication.Google; @@ -28,8 +24,7 @@ public class LoginController( LoggedInContext loggedInContext, EmailService emailService, UserService userService, - TurnstileService turnstileService, - ProjectService projectService) + TurnstileService turnstileService) : ControllerBase { /// @@ -38,7 +33,6 @@ public class LoginController( /// [HttpGet("loginRedirect")] [AllowAnyAudience] - public async Task LoginRedirect( string jwt, // This is required because auth looks for a jwt in the query string string returnTo) @@ -53,6 +47,7 @@ public async Task LoginRedirect( return await EmailLinkExpired(); } } + await HttpContext.SignInAsync(User, new AuthenticationProperties { IsPersistent = true }); return Redirect(returnTo); @@ -87,6 +82,7 @@ public async Task CompleteGoogleLogin(ClaimsPrincipal? principal, string { (authUser, userEntity) = await lexAuthService.GetUser(googleEmail); } + if (authUser is null) { authUser = new LexAuthUser() @@ -102,19 +98,20 @@ public async Task CompleteGoogleLogin(ClaimsPrincipal? principal, string Locale = locale ?? LexCore.Entities.User.DefaultLocalizationCode, Locked = null, }; - var queryParams = new Dictionary() { - {"email", googleEmail}, - {"name", googleName}, - {"returnTo", returnTo}, + var queryParams = new Dictionary() + { + { "email", googleEmail }, { "name", googleName }, { "returnTo", returnTo }, }; var queryString = QueryString.Create(queryParams); returnTo = "/register" + queryString.ToString(); } + if (userEntity is not null && !foundGoogleId) { userEntity.GoogleId = googleId; await lexBoxDbContext.SaveChangesAsync(); } + await HttpContext.SignInAsync(authUser.GetPrincipal("google"), new AuthenticationProperties { IsPersistent = true }); return returnTo; @@ -158,6 +155,7 @@ public async Task> VerifyEmail( return Redirect(returnTo); } + [HttpPost] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] @@ -219,7 +217,9 @@ public async Task ForgotPassword(ForgotPasswordInput input) return Ok(); } - public record ResetPasswordRequest([Required(AllowEmptyStrings = false)] string PasswordHash, int? PasswordStrength); + public record ResetPasswordRequest( + [Required(AllowEmptyStrings = false)] string PasswordHash, + int? PasswordStrength); [HttpPost("resetPassword")] [RequireAudience(LexboxAudience.ForgotPassword)] diff --git a/backend/LexBoxApi/Controllers/OauthController.cs b/backend/LexBoxApi/Controllers/OauthController.cs new file mode 100644 index 000000000..b7024a295 --- /dev/null +++ b/backend/LexBoxApi/Controllers/OauthController.cs @@ -0,0 +1,307 @@ +using System.Security.Claims; +using System.Text.Json; +using LexBoxApi.Auth; +using LexCore.Auth; +using Microsoft.AspNetCore; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Primitives; +using Microsoft.IdentityModel.Tokens; +using OpenIddict.Abstractions; +using OpenIddict.Server.AspNetCore; + +namespace LexBoxApi.Controllers; + +[ApiController] +[Route("/api/oauth")] +public class OauthController( + LoggedInContext loggedInContext, + IOpenIddictApplicationManager applicationManager, + IOpenIddictAuthorizationManager authorizationManager +) : ControllerBase +{ + + [HttpGet("open-id-auth")] + [HttpPost("open-id-auth")] + [ProducesResponseType(400)] + [ProducesDefaultResponseType] + public async Task Authorize() + { + var request = HttpContext.GetOpenIddictServerRequest(); + if (request is null) + { + return BadRequest(); + } + + if (IsAcceptRequest()) + { + return await FinishSignIn(loggedInContext.User, request); + } + + // Retrieve the user principal stored in the authentication cookie. + // If the user principal can't be extracted or the cookie is too old, redirect the user to the login page. + var result = await HttpContext.AuthenticateAsync(); + var lexAuthUser = result.Succeeded ? LexAuthUser.FromClaimsPrincipal(result.Principal) : null; + if (!result.Succeeded || + lexAuthUser is null || + request.HasPrompt(OpenIddictConstants.Prompts.Login) || + IsExpired(request, result)) + { + // If the client application requested promptless authentication, + // return an error indicating that the user is not logged in. + if (request.HasPrompt(OpenIddictConstants.Prompts.None)) + { + return Forbid( + authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, + properties: new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = + OpenIddictConstants.Errors.LoginRequired, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = + "The user is not logged in." + })); + } + + // To avoid endless login -> authorization redirects, the prompt=login flag + // is removed from the authorization request payload before redirecting the user. + var prompt = string.Join(" ", request.GetPrompts().Remove(OpenIddictConstants.Prompts.Login)); + + var parameters = Request.HasFormContentType + ? Request.Form.Where(parameter => parameter.Key != OpenIddictConstants.Parameters.Prompt).ToList() + : Request.Query.Where(parameter => parameter.Key != OpenIddictConstants.Parameters.Prompt).ToList(); + + parameters.Add(KeyValuePair.Create(OpenIddictConstants.Parameters.Prompt, new StringValues(prompt))); + + return Challenge( + authenticationSchemes: CookieAuthenticationDefaults.AuthenticationScheme, + properties: new AuthenticationProperties + { + RedirectUri = Request.PathBase + Request.Path + QueryString.Create(parameters) + }); + } + + var userId = lexAuthUser.Id.ToString(); + var requestClientId = request.ClientId; + ArgumentException.ThrowIfNullOrEmpty(requestClientId); + var application = await applicationManager.FindByClientIdAsync(requestClientId) ?? + throw new InvalidOperationException( + "Details concerning the calling client application cannot be found."); + var applicationId = await applicationManager.GetIdAsync(application) ?? + throw new InvalidOperationException("The calling client application could not be found."); + + // Retrieve the permanent authorizations associated with the user and the calling client application. + var authorizations = await authorizationManager.FindAsync( + subject: userId, + client: applicationId, + status: OpenIddictConstants.Statuses.Valid, + type: OpenIddictConstants.AuthorizationTypes.Permanent, + scopes: request.GetScopes()).ToListAsync(); + + switch (await applicationManager.GetConsentTypeAsync(application)) + { + // If the consent is implicit or if an authorization was found, + // return an authorization response without displaying the consent form. + case OpenIddictConstants.ConsentTypes.Implicit: + case OpenIddictConstants.ConsentTypes.External when authorizations.Count is not 0: + case OpenIddictConstants.ConsentTypes.Explicit when authorizations.Count is not 0 && !request.HasPrompt(OpenIddictConstants.Prompts.Consent): + + return await FinishSignIn(lexAuthUser, request, applicationId, authorizations); + + // If the consent is external (e.g when authorizations are granted by a sysadmin), + // immediately return an error if no authorization can be found in the database. + case OpenIddictConstants.ConsentTypes.External when authorizations.Count is 0: + return Forbid( + authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, + properties: new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = OpenIddictConstants.Errors.ConsentRequired, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = + "The logged in user is not allowed to access this client application." + })); + + // At this point, no authorization was found in the database and an error must be returned + // if the client application specified prompt=none in the authorization request. + case OpenIddictConstants.ConsentTypes.Explicit when request.HasPrompt(OpenIddictConstants.Prompts.None): + case OpenIddictConstants.ConsentTypes.Systematic when request.HasPrompt(OpenIddictConstants.Prompts.None): + return Forbid( + authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, + properties: new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = OpenIddictConstants.Errors.ConsentRequired, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = + "Interactive user consent is required." + })); + + // In every other case, send user to consent page + default: + var parameters = Request.HasFormContentType + ? Request.Form.ToList() + : Request.Query.ToList(); + var data = JsonSerializer.Serialize(parameters.ToDictionary(pair => pair.Key, pair => pair.Value.ToString())); + var queryString = new QueryString() + .Add("appName", await applicationManager.GetDisplayNameAsync(application) ?? "Unknown app") + .Add("scope", request.Scope ?? "") + .Add("postback", data); + return Redirect($"/authorize{queryString.Value}"); + } + } + + private static bool IsExpired(OpenIddictRequest request, AuthenticateResult result) + { + // If a max_age parameter was provided, ensure that the cookie is not too old. + return (request.MaxAge != null && result.Properties?.IssuedUtc != null && + DateTimeOffset.UtcNow - result.Properties.IssuedUtc > + TimeSpan.FromSeconds(request.MaxAge.Value)); + } + + private bool IsAcceptRequest() + { + return Request.Method == "POST" && Request.Form.ContainsKey("submit.accept") && User.Identity?.IsAuthenticated == true; + } + + [HttpPost("token")] + [AllowAnonymous] + public async Task Exchange() + { + var request = HttpContext.GetOpenIddictServerRequest() ?? + throw new InvalidOperationException("The OpenID Connect request cannot be retrieved."); + // Retrieve the claims principal stored in the authorization code/refresh token. + var result = await HttpContext.AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + var lexAuthUser = result.Succeeded ? LexAuthUser.FromClaimsPrincipal(result.Principal) : null; + if (!result.Succeeded || lexAuthUser is null) + { + return Forbid( + authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, + properties: new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = OpenIddictConstants.Errors.InvalidGrant, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = + "The token is no longer valid." + })); + } + + return await FinishSignIn(lexAuthUser, request); + } + + private async Task FinishSignIn(LexAuthUser lexAuthUser, OpenIddictRequest request) + { + var requestClientId = request.ClientId; + ArgumentException.ThrowIfNullOrEmpty(requestClientId); + var application = await applicationManager.FindByClientIdAsync(requestClientId) ?? + throw new InvalidOperationException( + "Details concerning the calling client application cannot be found."); + // Retrieve the permanent authorizations associated with the user and the calling client application. + var applicationId = await applicationManager.GetIdAsync(application) ?? throw new InvalidOperationException("The calling client application could not be found."); + var authorizations = await authorizationManager.FindAsync( + subject: lexAuthUser.Id.ToString(), + client: applicationId, + status: OpenIddictConstants.Statuses.Valid, + type: OpenIddictConstants.AuthorizationTypes.Permanent, + scopes: request.GetScopes()).ToListAsync(); + + //allow cors response for redirect hosts + var redirectUrisAsync = await applicationManager.GetRedirectUrisAsync(application); + Response.Headers.AccessControlAllowOrigin = redirectUrisAsync + .Select(uri => new Uri(uri).GetComponents(UriComponents.SchemeAndServer, UriFormat.Unescaped)).ToArray(); + + // Note: this check is here to ensure a malicious user can't abuse this POST-only endpoint and + // force it to return a valid response without the external authorization. + if (authorizations.Count is 0 && + await applicationManager.HasConsentTypeAsync(application, OpenIddictConstants.ConsentTypes.External)) + { + return Forbid( + authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, + properties: new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = OpenIddictConstants.Errors.ConsentRequired, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = + "The logged in user is not allowed to access this client application." + })); + } + + return await FinishSignIn(lexAuthUser, request, applicationId, authorizations); + } + private async Task FinishSignIn(LexAuthUser lexAuthUser, OpenIddictRequest request, string applicationId, List authorizations) + { + var userId = lexAuthUser.Id.ToString(); + // Create the claims-based identity that will be used by OpenIddict to generate tokens. + var identity = new ClaimsIdentity( + authenticationType: TokenValidationParameters.DefaultAuthenticationType, + nameType: OpenIddictConstants.Claims.Name, + roleType: OpenIddictConstants.Claims.Role); + + // Add the claims that will be persisted in the tokens. + identity.SetClaim(OpenIddictConstants.Claims.Subject, userId) + .SetClaim(OpenIddictConstants.Claims.Email, lexAuthUser.Email) + .SetClaim(OpenIddictConstants.Claims.Name, lexAuthUser.Name) + .SetClaim(OpenIddictConstants.Claims.Role, lexAuthUser.Role.ToString()); + + // Note: in this sample, the granted scopes match the requested scope + // but you may want to allow the user to uncheck specific scopes. + // For that, simply restrict the list of scopes before calling SetScopes. + identity.SetScopes(request.GetScopes()); + identity.SetAudiences(LexboxAudience.LexboxApi.ToString()); + // identity.SetResources(await _scopeManager.ListResourcesAsync(identity.GetScopes()).ToListAsync()); + + // Automatically create a permanent authorization to avoid requiring explicit consent + // for future authorization or token requests containing the same scopes. + var authorization = authorizations.LastOrDefault(); + authorization ??= await authorizationManager.CreateAsync( + identity: identity, + subject : userId, + client : applicationId, + type : OpenIddictConstants.AuthorizationTypes.Permanent, + scopes : identity.GetScopes()); + + identity.SetAuthorizationId(await authorizationManager.GetIdAsync(authorization)); + identity.SetDestinations(GetDestinations); + + // Returning a SignInResult will ask OpenIddict to issue the appropriate access/identity tokens. + return SignIn(new ClaimsPrincipal(identity), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + } + + private static IEnumerable GetDestinations(Claim claim) + { + // Note: by default, claims are NOT automatically included in the access and identity tokens. + // To allow OpenIddict to serialize them, you must attach them a destination, that specifies + // whether they should be included in access tokens, in identity tokens or in both. + + var claimsIdentity = claim.Subject; + ArgumentNullException.ThrowIfNull(claimsIdentity); + switch (claim.Type) + { + case OpenIddictConstants.Claims.Name: + yield return OpenIddictConstants.Destinations.AccessToken; + + if (claimsIdentity.HasScope(OpenIddictConstants.Scopes.Profile)) + yield return OpenIddictConstants.Destinations.IdentityToken; + + yield break; + + case OpenIddictConstants.Claims.Email: + yield return OpenIddictConstants.Destinations.AccessToken; + + if (claimsIdentity.HasScope(OpenIddictConstants.Scopes.Email)) + yield return OpenIddictConstants.Destinations.IdentityToken; + + yield break; + + case OpenIddictConstants.Claims.Role: + yield return OpenIddictConstants.Destinations.AccessToken; + + if (claimsIdentity.HasScope(OpenIddictConstants.Scopes.Roles)) + yield return OpenIddictConstants.Destinations.IdentityToken; + + yield break; + + // Never include the security stamp in the access and identity tokens, as it's a secret value. + case "AspNet.Identity.SecurityStamp": yield break; + + default: + yield return OpenIddictConstants.Destinations.AccessToken; + yield break; + } + } +} diff --git a/backend/LexBoxApi/LexBoxApi.csproj b/backend/LexBoxApi/LexBoxApi.csproj index deddc8b83..725215ac3 100644 --- a/backend/LexBoxApi/LexBoxApi.csproj +++ b/backend/LexBoxApi/LexBoxApi.csproj @@ -32,8 +32,12 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/backend/LexBoxApi/Program.cs b/backend/LexBoxApi/Program.cs index 31a98c0df..889b02293 100644 --- a/backend/LexBoxApi/Program.cs +++ b/backend/LexBoxApi/Program.cs @@ -171,6 +171,10 @@ .RequireAuthorization(new AdminRequiredAttribute()); // /api routes should never make it to this point, they should be handled by the controllers, so return 404 app.Map("/api/{**catch-all}", () => Results.NotFound()).AllowAnonymous(); + +//should normally be handled by svelte, but if it does reach this we need to return a 401, otherwise we'll get stuck in a redirect loop +app.Map("/login", Results.Unauthorized).AllowAnonymous(); + app.MapSyncProxy(AuthKernel.DefaultScheme); await app.RunAsync(); diff --git a/backend/LexBoxApi/appsettings.Development.json b/backend/LexBoxApi/appsettings.Development.json index ec867ac9d..077ee8967 100644 --- a/backend/LexBoxApi/appsettings.Development.json +++ b/backend/LexBoxApi/appsettings.Development.json @@ -7,7 +7,7 @@ } }, "DbConfig": { - "LexBoxConnectionString": "Host=localhost;Port=5433;Username=postgres;Password=972b722e63f549938d07bd8c4ee5086c;Database=lexbox;Include Error Detail=true", + "LexBoxConnectionString": "Host=localhost;Port=5433;Username=postgres;Password=972b722e63f549938d07bd8c4ee5086c;Database=lexbox;Include Error Detail=true" }, "LfClassicConfig": { "ConnectionString": "mongodb://localhost:27017", @@ -57,6 +57,9 @@ "Google": { "ClientId": "694338503380-rf3t82f3kqc7l0orjletk2r58lep0lvi.apps.googleusercontent.com", "ClientSecret": "__REPLACE__" + }, + "OpenId": { + "Enable": true } }, "Tus" : { diff --git a/backend/LexBoxApi/appsettings.json b/backend/LexBoxApi/appsettings.json index 526299b70..6f3e5ef2e 100644 --- a/backend/LexBoxApi/appsettings.json +++ b/backend/LexBoxApi/appsettings.json @@ -60,6 +60,9 @@ // properties declared in CookieAuthenticationOptions // 15 days, 0 hours "ExpireTimeSpan": "15.00:00" + }, + "OpenId": { + "Enable": false } }, "Otel": { diff --git a/backend/LexData/DataKernel.cs b/backend/LexData/DataKernel.cs index e89df4e26..90a9474d8 100644 --- a/backend/LexData/DataKernel.cs +++ b/backend/LexData/DataKernel.cs @@ -17,6 +17,7 @@ public static void AddLexData(this IServiceCollection services, options.EnableDetailedErrors(); options.UseNpgsql(serviceProvider.GetRequiredService>().Value.LexBoxConnectionString); options.UseProjectables(); + options.UseOpenIddict(); }, dbContextLifeTime); services.AddLogging(); services.AddHealthChecks() diff --git a/backend/LexData/LexData.csproj b/backend/LexData/LexData.csproj index 1ef9b465e..31a539006 100644 --- a/backend/LexData/LexData.csproj +++ b/backend/LexData/LexData.csproj @@ -10,17 +10,18 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + - + + diff --git a/backend/LexData/Migrations/20240515164328_AddOpenId.Designer.cs b/backend/LexData/Migrations/20240515164328_AddOpenId.Designer.cs new file mode 100644 index 000000000..95c2c35ef --- /dev/null +++ b/backend/LexData/Migrations/20240515164328_AddOpenId.Designer.cs @@ -0,0 +1,1271 @@ +// +using System; +using System.Collections.Generic; +using LexData; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace LexData.Migrations +{ + [DbContext(typeof(LexBoxDbContext))] + [Migration("20240515164328_AddOpenId")] + partial class AddOpenId + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("Npgsql:CollationDefinition:case_insensitive", "und-u-ks-level2,und-u-ks-level2,icu,False") + .HasAnnotation("ProductVersion", "8.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzBlobTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("BlobData") + .HasColumnType("bytea") + .HasColumnName("blob_data"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("qrtz_blob_triggers", "quartz"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzCalendar", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("CalendarName") + .HasColumnType("text") + .HasColumnName("calendar_name"); + + b.Property("Calendar") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("calendar"); + + b.HasKey("SchedulerName", "CalendarName"); + + b.ToTable("qrtz_calendars", "quartz"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzCronTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("text") + .HasColumnName("cron_expression"); + + b.Property("TimeZoneId") + .HasColumnType("text") + .HasColumnName("time_zone_id"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("qrtz_cron_triggers", "quartz"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzFiredTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("EntryId") + .HasColumnType("text") + .HasColumnName("entry_id"); + + b.Property("FiredTime") + .HasColumnType("bigint") + .HasColumnName("fired_time"); + + b.Property("InstanceName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("instance_name"); + + b.Property("IsNonConcurrent") + .HasColumnType("bool") + .HasColumnName("is_nonconcurrent"); + + b.Property("JobGroup") + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property("JobName") + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property("RequestsRecovery") + .HasColumnType("bool") + .HasColumnName("requests_recovery"); + + b.Property("ScheduledTime") + .HasColumnType("bigint") + .HasColumnName("sched_time"); + + b.Property("State") + .IsRequired() + .HasColumnType("text") + .HasColumnName("state"); + + b.Property("TriggerGroup") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("TriggerName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.HasKey("SchedulerName", "EntryId"); + + b.HasIndex("InstanceName") + .HasDatabaseName("idx_qrtz_ft_trig_inst_name"); + + b.HasIndex("JobGroup") + .HasDatabaseName("idx_qrtz_ft_job_group"); + + b.HasIndex("JobName") + .HasDatabaseName("idx_qrtz_ft_job_name"); + + b.HasIndex("RequestsRecovery") + .HasDatabaseName("idx_qrtz_ft_job_req_recovery"); + + b.HasIndex("TriggerGroup") + .HasDatabaseName("idx_qrtz_ft_trig_group"); + + b.HasIndex("TriggerName") + .HasDatabaseName("idx_qrtz_ft_trig_name"); + + b.HasIndex("SchedulerName", "TriggerName", "TriggerGroup") + .HasDatabaseName("idx_qrtz_ft_trig_nm_gp"); + + b.ToTable("qrtz_fired_triggers", "quartz"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzJobDetail", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("JobName") + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property("JobGroup") + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("IsDurable") + .HasColumnType("bool") + .HasColumnName("is_durable"); + + b.Property("IsNonConcurrent") + .HasColumnType("bool") + .HasColumnName("is_nonconcurrent"); + + b.Property("IsUpdateData") + .HasColumnType("bool") + .HasColumnName("is_update_data"); + + b.Property("JobClassName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_class_name"); + + b.Property("JobData") + .HasColumnType("bytea") + .HasColumnName("job_data"); + + b.Property("RequestsRecovery") + .HasColumnType("bool") + .HasColumnName("requests_recovery"); + + b.HasKey("SchedulerName", "JobName", "JobGroup"); + + b.HasIndex("RequestsRecovery") + .HasDatabaseName("idx_qrtz_j_req_recovery"); + + b.ToTable("qrtz_job_details", "quartz"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzLock", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("LockName") + .HasColumnType("text") + .HasColumnName("lock_name"); + + b.HasKey("SchedulerName", "LockName"); + + b.ToTable("qrtz_locks", "quartz"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzPausedTriggerGroup", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.HasKey("SchedulerName", "TriggerGroup"); + + b.ToTable("qrtz_paused_trigger_grps", "quartz"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSchedulerState", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("InstanceName") + .HasColumnType("text") + .HasColumnName("instance_name"); + + b.Property("CheckInInterval") + .HasColumnType("bigint") + .HasColumnName("checkin_interval"); + + b.Property("LastCheckInTime") + .HasColumnType("bigint") + .HasColumnName("last_checkin_time"); + + b.HasKey("SchedulerName", "InstanceName"); + + b.ToTable("qrtz_scheduler_state", "quartz"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSimplePropertyTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("BooleanProperty1") + .HasColumnType("bool") + .HasColumnName("bool_prop_1"); + + b.Property("BooleanProperty2") + .HasColumnType("bool") + .HasColumnName("bool_prop_2"); + + b.Property("DecimalProperty1") + .HasColumnType("numeric") + .HasColumnName("dec_prop_1"); + + b.Property("DecimalProperty2") + .HasColumnType("numeric") + .HasColumnName("dec_prop_2"); + + b.Property("IntegerProperty1") + .HasColumnType("integer") + .HasColumnName("int_prop_1"); + + b.Property("IntegerProperty2") + .HasColumnType("integer") + .HasColumnName("int_prop_2"); + + b.Property("LongProperty1") + .HasColumnType("bigint") + .HasColumnName("long_prop_1"); + + b.Property("LongProperty2") + .HasColumnType("bigint") + .HasColumnName("long_prop_2"); + + b.Property("StringProperty1") + .HasColumnType("text") + .HasColumnName("str_prop_1"); + + b.Property("StringProperty2") + .HasColumnType("text") + .HasColumnName("str_prop_2"); + + b.Property("StringProperty3") + .HasColumnType("text") + .HasColumnName("str_prop_3"); + + b.Property("TimeZoneId") + .HasColumnType("text") + .HasColumnName("time_zone_id"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("qrtz_simprop_triggers", "quartz"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSimpleTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("RepeatCount") + .HasColumnType("bigint") + .HasColumnName("repeat_count"); + + b.Property("RepeatInterval") + .HasColumnType("bigint") + .HasColumnName("repeat_interval"); + + b.Property("TimesTriggered") + .HasColumnType("bigint") + .HasColumnName("times_triggered"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("qrtz_simple_triggers", "quartz"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("CalendarName") + .HasColumnType("text") + .HasColumnName("calendar_name"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("EndTime") + .HasColumnType("bigint") + .HasColumnName("end_time"); + + b.Property("JobData") + .HasColumnType("bytea") + .HasColumnName("job_data"); + + b.Property("JobGroup") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property("JobName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property("MisfireInstruction") + .HasColumnType("smallint") + .HasColumnName("misfire_instr"); + + b.Property("NextFireTime") + .HasColumnType("bigint") + .HasColumnName("next_fire_time"); + + b.Property("PreviousFireTime") + .HasColumnType("bigint") + .HasColumnName("prev_fire_time"); + + b.Property("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property("StartTime") + .HasColumnType("bigint") + .HasColumnName("start_time"); + + b.Property("TriggerState") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_state"); + + b.Property("TriggerType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_type"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.HasIndex("NextFireTime") + .HasDatabaseName("idx_qrtz_t_next_fire_time"); + + b.HasIndex("TriggerState") + .HasDatabaseName("idx_qrtz_t_state"); + + b.HasIndex("NextFireTime", "TriggerState") + .HasDatabaseName("idx_qrtz_t_nft_st"); + + b.HasIndex("SchedulerName", "JobName", "JobGroup"); + + b.ToTable("qrtz_triggers", "quartz"); + }); + + modelBuilder.Entity("LexCore.Entities.DraftProject", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsConfidential") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectManagerId") + .HasColumnType("uuid"); + + b.Property("RetentionPolicy") + .HasColumnType("integer"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("ProjectManagerId"); + + b.ToTable("DraftProjects"); + }); + + modelBuilder.Entity("LexCore.Entities.FlexProjectMetadata", b => + { + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("LexEntryCount") + .HasColumnType("integer"); + + b.HasKey("ProjectId"); + + b.ToTable("FlexProjectMetadata"); + }); + + modelBuilder.Entity("LexCore.Entities.OrgMember", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("OrgId") + .HasColumnType("uuid"); + + b.Property("Role") + .HasColumnType("integer"); + + b.Property("UpdatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrgId"); + + b.HasIndex("UserId", "OrgId") + .IsUnique(); + + b.ToTable("OrgMembers", (string)null); + }); + + modelBuilder.Entity("LexCore.Entities.Organization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Orgs", (string)null); + }); + + modelBuilder.Entity("LexCore.Entities.Project", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("DeletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("IsConfidential") + .HasColumnType("boolean"); + + b.Property("LastCommit") + .HasColumnType("timestamp with time zone"); + + b.Property("MigratedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ParentId") + .HasColumnType("uuid"); + + b.Property("ProjectOrigin") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.Property("ResetStatus") + .HasColumnType("integer"); + + b.Property("RetentionPolicy") + .HasColumnType("integer"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("ParentId"); + + b.ToTable("Projects"); + }); + + modelBuilder.Entity("LexCore.Entities.ProjectUsers", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("Role") + .HasColumnType("integer"); + + b.Property("UpdatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.HasIndex("UserId", "ProjectId") + .IsUnique(); + + b.ToTable("ProjectUsers"); + }); + + modelBuilder.Entity("LexCore.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CanCreateProjects") + .HasColumnType("boolean"); + + b.Property("CreatedById") + .HasColumnType("uuid"); + + b.Property("CreatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Email") + .HasColumnType("text") + .UseCollation("case_insensitive"); + + b.Property("EmailVerified") + .HasColumnType("boolean"); + + b.Property("GoogleId") + .HasColumnType("text"); + + b.Property("IsAdmin") + .HasColumnType("boolean"); + + b.Property("LastActive") + .HasColumnType("timestamp with time zone"); + + b.Property("LocalizationCode") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("en"); + + b.Property("Locked") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("PasswordStrength") + .HasColumnType("integer"); + + b.Property("Salt") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Username") + .HasColumnType("text") + .UseCollation("case_insensitive"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("LexData.Entities.CrdtCommit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClientId") + .HasColumnType("uuid"); + + b.Property("Hash") + .IsRequired() + .HasColumnType("text"); + + b.Property("ParentHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.ComplexProperty>("HybridDateTime", "LexData.Entities.CrdtCommit.HybridDateTime#HybridDateTime", b1 => + { + b1.IsRequired(); + + b1.Property("Counter") + .HasColumnType("bigint"); + + b1.Property("DateTime") + .HasColumnType("timestamp with time zone"); + }); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.ToTable("CrdtCommits", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ClientId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ClientSecret") + .HasColumnType("text"); + + b.Property("ClientType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ConsentType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("DisplayNames") + .HasColumnType("text"); + + b.Property("JsonWebKeySet") + .HasColumnType("text"); + + b.Property("Permissions") + .HasColumnType("text"); + + b.Property("PostLogoutRedirectUris") + .HasColumnType("text"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RedirectUris") + .HasColumnType("text"); + + b.Property("Requirements") + .HasColumnType("text"); + + b.Property("Settings") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ClientId") + .IsUnique(); + + b.ToTable("OpenIddictApplications", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("Scopes") + .HasColumnType("text"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("Type") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddictAuthorizations", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreScope", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Descriptions") + .HasColumnType("text"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("DisplayNames") + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("Resources") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("OpenIddictScopes", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("text"); + + b.Property("AuthorizationId") + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Payload") + .HasColumnType("text"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RedemptionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ReferenceId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("Type") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("AuthorizationId"); + + b.HasIndex("ReferenceId") + .IsUnique(); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddictTokens", (string)null); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzBlobTrigger", b => + { + b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", "Trigger") + .WithMany("BlobTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzCronTrigger", b => + { + b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", "Trigger") + .WithMany("CronTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSimplePropertyTrigger", b => + { + b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", "Trigger") + .WithMany("SimplePropertyTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSimpleTrigger", b => + { + b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", "Trigger") + .WithMany("SimpleTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", b => + { + b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzJobDetail", "JobDetail") + .WithMany("Triggers") + .HasForeignKey("SchedulerName", "JobName", "JobGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobDetail"); + }); + + modelBuilder.Entity("LexCore.Entities.DraftProject", b => + { + b.HasOne("LexCore.Entities.User", "ProjectManager") + .WithMany() + .HasForeignKey("ProjectManagerId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("ProjectManager"); + }); + + modelBuilder.Entity("LexCore.Entities.FlexProjectMetadata", b => + { + b.HasOne("LexCore.Entities.Project", null) + .WithOne("FlexProjectMetadata") + .HasForeignKey("LexCore.Entities.FlexProjectMetadata", "ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("LexCore.Entities.OrgMember", b => + { + b.HasOne("LexCore.Entities.Organization", "Organization") + .WithMany("Members") + .HasForeignKey("OrgId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LexCore.Entities.User", "User") + .WithMany("Organizations") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("LexCore.Entities.Project", b => + { + b.HasOne("LexCore.Entities.Project", null) + .WithMany() + .HasForeignKey("ParentId"); + }); + + modelBuilder.Entity("LexCore.Entities.ProjectUsers", b => + { + b.HasOne("LexCore.Entities.Project", "Project") + .WithMany("Users") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LexCore.Entities.User", "User") + .WithMany("Projects") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("LexCore.Entities.User", b => + { + b.HasOne("LexCore.Entities.User", "CreatedBy") + .WithMany("UsersICreated") + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("CreatedBy"); + }); + + modelBuilder.Entity("LexData.Entities.CrdtCommit", b => + { + b.HasOne("LexCore.Entities.FlexProjectMetadata", null) + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsMany("Crdt.Core.ChangeEntity", "ChangeEntities", b1 => + { + b1.Property("CrdtCommitId") + .HasColumnType("uuid"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + b1.Property("Change") + .HasColumnType("text"); + + b1.Property("CommitId") + .HasColumnType("uuid"); + + b1.Property("EntityId") + .HasColumnType("uuid"); + + b1.Property("Index") + .HasColumnType("integer"); + + b1.HasKey("CrdtCommitId", "Id"); + + b1.ToTable("CrdtCommits"); + + b1.ToJson("ChangeEntities"); + + b1.WithOwner() + .HasForeignKey("CrdtCommitId"); + }); + + b.Navigation("ChangeEntities"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Authorizations") + .HasForeignKey("ApplicationId"); + + b.Navigation("Application"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Tokens") + .HasForeignKey("ApplicationId"); + + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", "Authorization") + .WithMany("Tokens") + .HasForeignKey("AuthorizationId"); + + b.Navigation("Application"); + + b.Navigation("Authorization"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzJobDetail", b => + { + b.Navigation("Triggers"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", b => + { + b.Navigation("BlobTriggers"); + + b.Navigation("CronTriggers"); + + b.Navigation("SimplePropertyTriggers"); + + b.Navigation("SimpleTriggers"); + }); + + modelBuilder.Entity("LexCore.Entities.Organization", b => + { + b.Navigation("Members"); + }); + + modelBuilder.Entity("LexCore.Entities.Project", b => + { + b.Navigation("FlexProjectMetadata"); + + b.Navigation("Users"); + }); + + modelBuilder.Entity("LexCore.Entities.User", b => + { + b.Navigation("Organizations"); + + b.Navigation("Projects"); + + b.Navigation("UsersICreated"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Navigation("Authorizations"); + + b.Navigation("Tokens"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Navigation("Tokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/LexData/Migrations/20240515164328_AddOpenId.cs b/backend/LexData/Migrations/20240515164328_AddOpenId.cs new file mode 100644 index 000000000..952d9f453 --- /dev/null +++ b/backend/LexData/Migrations/20240515164328_AddOpenId.cs @@ -0,0 +1,166 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace LexData.Migrations +{ + /// + public partial class AddOpenId : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "OpenIddictApplications", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + ApplicationType = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + ClientId = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + ClientSecret = table.Column(type: "text", nullable: true), + ClientType = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + ConcurrencyToken = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + ConsentType = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + DisplayName = table.Column(type: "text", nullable: true), + DisplayNames = table.Column(type: "text", nullable: true), + JsonWebKeySet = table.Column(type: "text", nullable: true), + Permissions = table.Column(type: "text", nullable: true), + PostLogoutRedirectUris = table.Column(type: "text", nullable: true), + Properties = table.Column(type: "text", nullable: true), + RedirectUris = table.Column(type: "text", nullable: true), + Requirements = table.Column(type: "text", nullable: true), + Settings = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_OpenIddictApplications", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "OpenIddictScopes", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + ConcurrencyToken = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + Description = table.Column(type: "text", nullable: true), + Descriptions = table.Column(type: "text", nullable: true), + DisplayName = table.Column(type: "text", nullable: true), + DisplayNames = table.Column(type: "text", nullable: true), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + Properties = table.Column(type: "text", nullable: true), + Resources = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_OpenIddictScopes", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "OpenIddictAuthorizations", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + ApplicationId = table.Column(type: "text", nullable: true), + ConcurrencyToken = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + CreationDate = table.Column(type: "timestamp with time zone", nullable: true), + Properties = table.Column(type: "text", nullable: true), + Scopes = table.Column(type: "text", nullable: true), + Status = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + Subject = table.Column(type: "character varying(400)", maxLength: 400, nullable: true), + Type = table.Column(type: "character varying(50)", maxLength: 50, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_OpenIddictAuthorizations", x => x.Id); + table.ForeignKey( + name: "FK_OpenIddictAuthorizations_OpenIddictApplications_Application~", + column: x => x.ApplicationId, + principalTable: "OpenIddictApplications", + principalColumn: "Id"); + }); + + migrationBuilder.CreateTable( + name: "OpenIddictTokens", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + ApplicationId = table.Column(type: "text", nullable: true), + AuthorizationId = table.Column(type: "text", nullable: true), + ConcurrencyToken = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + CreationDate = table.Column(type: "timestamp with time zone", nullable: true), + ExpirationDate = table.Column(type: "timestamp with time zone", nullable: true), + Payload = table.Column(type: "text", nullable: true), + Properties = table.Column(type: "text", nullable: true), + RedemptionDate = table.Column(type: "timestamp with time zone", nullable: true), + ReferenceId = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + Status = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + Subject = table.Column(type: "character varying(400)", maxLength: 400, nullable: true), + Type = table.Column(type: "character varying(50)", maxLength: 50, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_OpenIddictTokens", x => x.Id); + table.ForeignKey( + name: "FK_OpenIddictTokens_OpenIddictApplications_ApplicationId", + column: x => x.ApplicationId, + principalTable: "OpenIddictApplications", + principalColumn: "Id"); + table.ForeignKey( + name: "FK_OpenIddictTokens_OpenIddictAuthorizations_AuthorizationId", + column: x => x.AuthorizationId, + principalTable: "OpenIddictAuthorizations", + principalColumn: "Id"); + }); + + migrationBuilder.CreateIndex( + name: "IX_OpenIddictApplications_ClientId", + table: "OpenIddictApplications", + column: "ClientId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_OpenIddictAuthorizations_ApplicationId_Status_Subject_Type", + table: "OpenIddictAuthorizations", + columns: new[] { "ApplicationId", "Status", "Subject", "Type" }); + + migrationBuilder.CreateIndex( + name: "IX_OpenIddictScopes_Name", + table: "OpenIddictScopes", + column: "Name", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_OpenIddictTokens_ApplicationId_Status_Subject_Type", + table: "OpenIddictTokens", + columns: new[] { "ApplicationId", "Status", "Subject", "Type" }); + + migrationBuilder.CreateIndex( + name: "IX_OpenIddictTokens_AuthorizationId", + table: "OpenIddictTokens", + column: "AuthorizationId"); + + migrationBuilder.CreateIndex( + name: "IX_OpenIddictTokens_ReferenceId", + table: "OpenIddictTokens", + column: "ReferenceId", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "OpenIddictScopes"); + + migrationBuilder.DropTable( + name: "OpenIddictTokens"); + + migrationBuilder.DropTable( + name: "OpenIddictAuthorizations"); + + migrationBuilder.DropTable( + name: "OpenIddictApplications"); + } + } +} diff --git a/backend/LexData/Migrations/LexBoxDbContextModelSnapshot.cs b/backend/LexData/Migrations/LexBoxDbContextModelSnapshot.cs index e0a9d006c..77c540a3e 100644 --- a/backend/LexData/Migrations/LexBoxDbContextModelSnapshot.cs +++ b/backend/LexData/Migrations/LexBoxDbContextModelSnapshot.cs @@ -19,7 +19,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) #pragma warning disable 612, 618 modelBuilder .HasAnnotation("Npgsql:CollationDefinition:case_insensitive", "und-u-ks-level2,und-u-ks-level2,icu,False") - .HasAnnotation("ProductVersion", "8.0.4") + .HasAnnotation("ProductVersion", "8.0.5") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); @@ -773,6 +773,214 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Users"); }); + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ClientId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ClientSecret") + .HasColumnType("text"); + + b.Property("ClientType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ConsentType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("DisplayNames") + .HasColumnType("text"); + + b.Property("JsonWebKeySet") + .HasColumnType("text"); + + b.Property("Permissions") + .HasColumnType("text"); + + b.Property("PostLogoutRedirectUris") + .HasColumnType("text"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RedirectUris") + .HasColumnType("text"); + + b.Property("Requirements") + .HasColumnType("text"); + + b.Property("Settings") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ClientId") + .IsUnique(); + + b.ToTable("OpenIddictApplications", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("Scopes") + .HasColumnType("text"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("Type") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddictAuthorizations", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreScope", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Descriptions") + .HasColumnType("text"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("DisplayNames") + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("Resources") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("OpenIddictScopes", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("text"); + + b.Property("AuthorizationId") + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Payload") + .HasColumnType("text"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RedemptionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ReferenceId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("Type") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("AuthorizationId"); + + b.HasIndex("ReferenceId") + .IsUnique(); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddictTokens", (string)null); + }); + modelBuilder.Entity("LexData.Entities.CrdtCommit", b => { b.Property("Id") @@ -944,6 +1152,30 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("CreatedBy"); }); + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Authorizations") + .HasForeignKey("ApplicationId"); + + b.Navigation("Application"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Tokens") + .HasForeignKey("ApplicationId"); + + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", "Authorization") + .WithMany("Tokens") + .HasForeignKey("AuthorizationId"); + + b.Navigation("Application"); + + b.Navigation("Authorization"); + }); + modelBuilder.Entity("LexData.Entities.CrdtCommit", b => { b.HasOne("LexCore.Entities.FlexProjectMetadata", null) @@ -1022,6 +1254,18 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("UsersICreated"); }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Navigation("Authorizations"); + + b.Navigation("Tokens"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Navigation("Tokens"); + }); #pragma warning restore 612, 618 } } diff --git a/backend/LexData/SeedingData.cs b/backend/LexData/SeedingData.cs index 2dae0edc6..0d4bb4994 100644 --- a/backend/LexData/SeedingData.cs +++ b/backend/LexData/SeedingData.cs @@ -4,10 +4,11 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Options; +using OpenIddict.Abstractions; namespace LexData; -public class SeedingData(LexBoxDbContext lexBoxDbContext, IOptions dbConfig, IHostEnvironment environment) +public class SeedingData(LexBoxDbContext lexBoxDbContext, IOptions dbConfig, IHostEnvironment environment, IOpenIddictApplicationManager? applicationManager = null) { public static readonly Guid TestAdminId = new("cf430ec9-e721-450a-b6a1-9a853212590b"); public static readonly Guid QaAdminId = new("99b00c58-0dc7-4fe4-b6f2-c27b828811e0"); @@ -16,17 +17,24 @@ public class SeedingData(LexBoxDbContext lexBoxDbContext, IOptions dbC public async Task SeedIfNoUsers(CancellationToken cancellationToken = default) { + await SeedOpenId(cancellationToken); if (await lexBoxDbContext.Users.CountAsync(cancellationToken) > 0) { return; } - await SeedDatabase(cancellationToken); + await SeedUserData(cancellationToken); + } + + public async Task SeedDatabase(CancellationToken cancellationToken = default) + { + await SeedOpenId(cancellationToken); + await SeedUserData(cancellationToken); } private const string PwSalt = "password-salt"; - public async Task SeedDatabase(CancellationToken cancellationToken = default) + private async Task SeedUserData(CancellationToken cancellationToken = default) { if (environment.IsProduction()) return; //NOTE: When seeding make sure you provide a constant Id like I have done here, @@ -167,6 +175,36 @@ public async Task SeedDatabase(CancellationToken cancellationToken = default) await lexBoxDbContext.SaveChangesAsync(cancellationToken); } + private async Task SeedOpenId(CancellationToken cancellationToken = default) + { + if (applicationManager is null) return; + const string clientId = "becf2856-0690-434b-b192-a4032b72067f"; + if (await applicationManager.FindByClientIdAsync(clientId, cancellationToken) is null) + { + await applicationManager.CreateAsync(new OpenIddictApplicationDescriptor + { + ClientId = clientId,//must be guid for MSAL + ClientType = OpenIddictConstants.ClientTypes.Public, + ApplicationType = OpenIddictConstants.ApplicationTypes.Web, + DisplayName = "Oidc Debugger", + //explicit requires the user to consent, Implicit does not, External requires an admin to approve, not currently supported + ConsentType = OpenIddictConstants.ConsentTypes.Explicit, + Permissions = + { + OpenIddictConstants.Permissions.Endpoints.Authorization, + OpenIddictConstants.Permissions.Endpoints.Token, + OpenIddictConstants.Permissions.GrantTypes.AuthorizationCode, + OpenIddictConstants.Permissions.GrantTypes.RefreshToken, + OpenIddictConstants.Permissions.ResponseTypes.Code, + OpenIddictConstants.Permissions.Scopes.Email, + OpenIddictConstants.Permissions.Scopes.Profile + }, + RedirectUris = { new Uri("https://oidcdebugger.com/debug")} + }, + cancellationToken); + } + } + public async Task CleanUpSeedData() { await lexBoxDbContext.Users.Where(u => u.Salt == PwSalt).ExecuteDeleteAsync(); diff --git a/backend/Testing/Fixtures/TestingServicesFixture.cs b/backend/Testing/Fixtures/TestingServicesFixture.cs index de8e19d15..df2a4e5d2 100644 --- a/backend/Testing/Fixtures/TestingServicesFixture.cs +++ b/backend/Testing/Fixtures/TestingServicesFixture.cs @@ -34,7 +34,7 @@ private static void ConfigureBaseServices(IServiceCollection services) EnvironmentName = Environments.Development }); services.AddSingleton(new ConfigurationManager()); - services.AddLexData(true, ServiceLifetime.Singleton); + services.AddLexData(true, dbContextLifeTime: ServiceLifetime.Singleton); } public ServiceProvider ConfigureServices(Action? configureServices = null) diff --git a/backend/Testing/Testing.csproj b/backend/Testing/Testing.csproj index 57b5d8306..d9a754155 100644 --- a/backend/Testing/Testing.csproj +++ b/backend/Testing/Testing.csproj @@ -38,7 +38,6 @@ - runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/frontend/src/lib/components/SigninWithGoogleButton.svelte b/frontend/src/lib/components/SigninWithGoogleButton.svelte index 5be17e2da..fa341ef43 100644 --- a/frontend/src/lib/components/SigninWithGoogleButton.svelte +++ b/frontend/src/lib/components/SigninWithGoogleButton.svelte @@ -12,7 +12,7 @@ - commented out overflow: hidden, because it's unnecessary and makes text ugly on click (due to .btn) - added media query for dark mode using colors from the guidelines --> - +
diff --git a/frontend/src/lib/icons/Icon.svelte b/frontend/src/lib/icons/Icon.svelte index 34972528e..435f41abc 100644 --- a/frontend/src/lib/icons/Icon.svelte +++ b/frontend/src/lib/icons/Icon.svelte @@ -1,6 +1,6 @@ + + + + diff --git a/frontend/src/routes/(authenticated)/authorize/+page.svelte b/frontend/src/routes/(authenticated)/authorize/+page.svelte new file mode 100644 index 000000000..0e09dcff2 --- /dev/null +++ b/frontend/src/routes/(authenticated)/authorize/+page.svelte @@ -0,0 +1,73 @@ + + +
+
+
+
+
+
+ +
+

+ Authorize "{data.appName}" +

+
+ {data.appName} wants to access your account. +
+
+
+
+ {#each data.scope?.split(' ') ?? [] as scope} + {#if scope === 'profile'} +
+ +
+
Personal user data
+
Name, email address and/or username (read-only)
+
+
+ {/if} + {#if scope === 'profile'} +
+ +
+
Projects
+
Project membership and roles (read-only)
+
+
+ {/if} + {/each} +
+
+
+ {#each Object.entries(data.postback) as [key, value]} + + + {/each} +
+
+ {data.appName} is trusted by Language Depot + +
+
+ + +
+
+
+
+
diff --git a/frontend/src/routes/(authenticated)/authorize/+page.ts b/frontend/src/routes/(authenticated)/authorize/+page.ts new file mode 100644 index 000000000..e697d0d16 --- /dev/null +++ b/frontend/src/routes/(authenticated)/authorize/+page.ts @@ -0,0 +1,9 @@ +import type { PageLoadEvent } from './$types'; + +export function load(event: PageLoadEvent) { + return { + appName: event.url.searchParams.get('appName') as string, + scope: event.url.searchParams.get('scope'), + postback: JSON.parse(event.url.searchParams.get('postback') ?? '{}') as Record + }; +} diff --git a/frontend/src/routes/(unauthenticated)/login/+page.svelte b/frontend/src/routes/(unauthenticated)/login/+page.svelte index 977d7b6e5..0fe2c3fc4 100644 --- a/frontend/src/routes/(unauthenticated)/login/+page.svelte +++ b/frontend/src/routes/(unauthenticated)/login/+page.svelte @@ -1,25 +1,28 @@ @@ -55,7 +80,7 @@
@@ -111,8 +136,8 @@
- - + +
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 84f9c64e9..3879e3cbf 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -4,7 +4,7 @@ import { defineConfig } from 'vitest/config'; import { gqlOptions } from './gql-codegen'; // eslint-disable-next-line no-restricted-imports import precompileIntl from 'svelte-intl-precompile/sveltekit-plugin'; -import {searchForWorkspaceRoot} from 'vite'; +import {type ProxyOptions, searchForWorkspaceRoot} from 'vite'; import { sveltekit } from '@sveltejs/kit/vite'; @@ -12,6 +12,10 @@ import { sveltekit } from '@sveltejs/kit/vite'; const exposeServer = false; +const lexboxServer: ProxyOptions = { + target: 'http://localhost:5158', + secure: false +}; export default defineConfig({ build: { @@ -30,7 +34,7 @@ export default defineConfig({ codegen(gqlOptions), precompileIntl('src/lib/i18n/locales'), sveltekit(), - exposeServer ? basicSsl() : null, // crypto.subtle is only availble on secure connections + exposeServer ? basicSsl() : null, // crypto.subtle is only available on secure connections ], optimizeDeps: { }, @@ -47,15 +51,10 @@ export default defineConfig({ ] }, proxy: process.env['DockerDev'] ? undefined : { - '/v1/traces': { - target: 'http://localhost:4318' - }, - '/api': { - target: 'http://localhost:5158' - }, - '/hg': { - target: 'http://localhost:5158' - } + '/v1/traces': 'http://localhost:4318', + '/api': lexboxServer, + '/hg': lexboxServer, + '/.well-known': lexboxServer } }, }); From dd259025fcf3a902b70763428f8ee4c0fe6af75e Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Tue, 28 May 2024 23:39:54 +0700 Subject: [PATCH 17/19] Create codeql.yml --- .github/workflows/codeql.yml | 59 ++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 .github/workflows/codeql.yml diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 000000000..492828ad7 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,59 @@ +name: "CodeQL" + +on: + push: + branches: [ "develop", "main" ] + pull_request: + branches: [ "develop", "main" ] + schedule: + - cron: '34 21 * * 2' + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + # Runner size impacts CodeQL analysis time. To learn more, please see: + # - https://gh.io/recommended-hardware-resources-for-running-codeql + # - https://gh.io/supported-runners-and-hardware-resources + # - https://gh.io/using-larger-runners (GitHub.com only) + # Consider using larger runners or machines with greater resources for possible analysis time improvements. + runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} + timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }} + permissions: + # required for all workflows + security-events: write + + # required to fetch internal or private CodeQL packs + packages: read + + strategy: + fail-fast: false + matrix: + include: + - language: csharp + build-mode: autobuild + - language: javascript-typescript + build-mode: none + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + submodules: true + + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{matrix.language}}" From 19ae3c6c17168ae4abfc959047571c6cf4a5eabb Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Tue, 28 May 2024 17:13:01 -0600 Subject: [PATCH 18/19] only deploy hgweb for the push event on develop. Checkout main on fleet repo explicitly. Prevent images from getting tagged as latest when running manual deploy, latest should only be for main. --- .github/workflows/deploy-branch.yaml | 6 +++--- .github/workflows/deploy.yaml | 1 + .github/workflows/lexbox-hgweb.yaml | 4 ++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/deploy-branch.yaml b/.github/workflows/deploy-branch.yaml index d316ac42c..f8a199eff 100644 --- a/.github/workflows/deploy-branch.yaml +++ b/.github/workflows/deploy-branch.yaml @@ -23,7 +23,7 @@ jobs: uses: ./.github/workflows/lexbox-api.yaml with: version: ${{ needs.set-version.outputs.version }} - label-latest: true + label-latest: false build-ui: name: Build UI @@ -31,7 +31,7 @@ jobs: uses: ./.github/workflows/lexbox-ui.yaml with: version: ${{ needs.set-version.outputs.version }} - label-latest: true + label-latest: false build-hgweb: name: Build hgweb @@ -39,7 +39,7 @@ jobs: uses: ./.github/workflows/lexbox-hgweb.yaml with: version: ${{ needs.set-version.outputs.version }} - label-latest: true + label-latest: false deploy: name: Deploy Develop diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index b9b94a3cc..0ab79e53e 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -66,6 +66,7 @@ jobs: repository: ${{ vars.FLEET_REPO }} ssh-key: ${{ secrets.FLEET_REPO_SSH_KEY }} path: fleet + ref: main - name: Copy yaml resources to fleet repo run: | cp lexbox/deployment/${{ inputs.k8s-environment }}/resources.yaml fleet/${{ inputs.k8s-environment }}/ diff --git a/.github/workflows/lexbox-hgweb.yaml b/.github/workflows/lexbox-hgweb.yaml index 4fdf066db..317270647 100644 --- a/.github/workflows/lexbox-hgweb.yaml +++ b/.github/workflows/lexbox-hgweb.yaml @@ -80,11 +80,11 @@ jobs: labels: ${{ steps.meta.outputs.labels }} deploy-hgweb: uses: ./.github/workflows/deploy.yaml - if: ${{github.ref == 'refs/heads/develop'}} + if: ${{github.ref == 'refs/heads/develop' && github.event_name == 'push'}} secrets: inherit needs: publish-hgweb with: version: ${{ needs.publish-hgweb.outputs.version }} image: 'ghcr.io/sillsdev/lexbox-hgweb' - deploy-domain: 'develop.lexbox.org' + deploy-domain: lexbox.dev.languagetechnology.org k8s-environment: 'develop' From ecb9ebb18f439d354a54f216c1e594212b21b991 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Tue, 28 May 2024 15:29:08 -0600 Subject: [PATCH 19/19] update hgresumable version --- deployment/base/hg-deployment.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deployment/base/hg-deployment.yaml b/deployment/base/hg-deployment.yaml index d7c2bd5f3..3e570d1ca 100644 --- a/deployment/base/hg-deployment.yaml +++ b/deployment/base/hg-deployment.yaml @@ -135,7 +135,7 @@ spec: - name: hgresumable - image: ghcr.io/sillsdev/hgresume:v2023-07-12 + image: ghcr.io/sillsdev/hgresume:v2024-05-28 # https://kubernetes.io/docs/concepts/configuration/manage-resources-containers resources: requests: