diff --git a/.dockerignore b/.dockerignore index af50df1a9..bfde9bf96 100644 --- a/.dockerignore +++ b/.dockerignore @@ -19,7 +19,8 @@ **/node_modules **/npm-debug.log **/obj +**/artifacts/publish **/secrets.dev.yaml **/values.dev.yaml LICENSE -README.md \ No newline at end of file +README.md diff --git a/.github/workflows/deploy-branch.yaml b/.github/workflows/deploy-branch.yaml index f8a199eff..5746a2520 100644 --- a/.github/workflows/deploy-branch.yaml +++ b/.github/workflows/deploy-branch.yaml @@ -41,10 +41,18 @@ jobs: version: ${{ needs.set-version.outputs.version }} label-latest: false + build-fw-headless: + name: Build FwHeadless + needs: [ set-version ] + uses: ./.github/workflows/lexbox-fw-headless.yaml + with: + version: ${{ needs.set-version.outputs.version }} + label-latest: false + deploy: name: Deploy Develop uses: ./.github/workflows/deploy.yaml - needs: [ build-api, build-ui, build-hgweb, set-version ] + needs: [ build-api, build-ui, build-hgweb, build-fw-headless, set-version ] secrets: inherit with: version: ${{ needs.set-version.outputs.version }} diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 0ab79e53e..45d5e8045 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -50,6 +50,7 @@ jobs: url: https://${{ inputs.deploy-domain }} outputs: api-version: ${{ steps.get-api-version.outputs.result }} + fw-headless-version: ${{ steps.get-fw-headless-version.outputs.result }} ui-version: ${{ steps.get-ui-version.outputs.result }} steps: - name: Checkout lexbox repo @@ -80,6 +81,11 @@ jobs: id: get-api-version with: cmd: yq '.images.[] | select(.name == "ghcr.io/sillsdev/lexbox-api").newTag' "fleet/${{ inputs.k8s-environment }}/kustomization.yaml" + - name: Get FwHeadless version + uses: mikefarah/yq@0b34c9a00de1c575a34eea05af1d956a525c4fc1 # v4.34.2 + id: get-fw-headless-version + with: + cmd: yq '.images.[] | select(.name == "ghcr.io/sillsdev/lexbox-fw-headless").newTag' "fleet/${{ inputs.k8s-environment }}/kustomization.yaml" - name: Get UI version uses: mikefarah/yq@0b34c9a00de1c575a34eea05af1d956a525c4fc1 # v4.34.2 id: get-ui-version diff --git a/.github/workflows/develop-fw-headless.yaml b/.github/workflows/develop-fw-headless.yaml new file mode 100644 index 000000000..6037f51a2 --- /dev/null +++ b/.github/workflows/develop-fw-headless.yaml @@ -0,0 +1,72 @@ +name: Develop FwHeadless CI/CD +on: + workflow_dispatch: + push: + paths: + - 'backend/FwHeadless/**' + - 'backend/FixFwData/**' + - 'backend/FwLite/FwDataMiniLcmBridge/**' + - 'backend/FwLite/LcmCrdt/**' + - 'backend/FwLite/MiniLcm/**' + - 'backend/FwLiteProjectSync/FwLiteProjectSync/**' + - 'backend/LexCore/**' + - 'backend/LexData/**' + - '.github/workflows/lexbox-fw-headless.yaml' + - '.github/workflows/deploy.yaml' + - 'deployment/base/fw-headless-deployment.yaml' + branches: + - develop + pull_request: + paths: + - 'backend/FwHeadless/**' + - 'backend/FixFwData/**' + - 'backend/FwLite/FwDataMiniLcmBridge/**' + - 'backend/FwLite/LcmCrdt/**' + - 'backend/FwLite/MiniLcm/**' + - 'backend/FwLiteProjectSync/FwLiteProjectSync/**' + - 'backend/LexCore/**' + - 'backend/LexData/**' + - '.github/workflows/lexbox-fw-headless.yaml' + - '.github/workflows/deploy.yaml' + - 'deployment/base/fw-headless-deployment.yaml' + branches: + - develop + +jobs: + set-version: + name: Set Version + runs-on: ubuntu-latest + outputs: + version: ${{ steps.setVersion.outputs.VERSION }} + steps: + - name: Set Version + id: setVersion + # set version to date in vYYYY-MM-DD-commitSha format + run: | + shortSha=$(echo ${{ github.sha }} | cut -c1-8) + echo "VERSION=v$(date --rfc-3339=date)-$shortSha" >> ${GITHUB_OUTPUT} + build-fw-headless: + name: Build FwHeadless + needs: set-version + uses: ./.github/workflows/lexbox-fw-headless.yaml + with: + version: ${{ needs.set-version.outputs.version }} + deploy-fw-headless: + name: Deploy FwHeadless + if: ${{github.ref == 'refs/heads/develop'}} + needs: [ build-fw-headless, set-version ] + uses: ./.github/workflows/deploy.yaml + secrets: inherit + with: + version: ${{ needs.set-version.outputs.version }} + image: 'ghcr.io/sillsdev/lexbox-fw-headless' + k8s-environment: develop + deploy-domain: lexbox.dev.languagetechnology.org + + # TODO: Run FwHeadless tests once we have developed them, but we don't need to run the whole integration test suite if only FwHeadless changes are being pushed + # integration-test-gha: + # name: GHA integration tests + # needs: [build-fw-headless, set-version] + # uses: ./.github/workflows/integration-test-gha.yaml + # with: + # lexbox-fw-headless-tag: ${{ needs.set-version.outputs.version }} diff --git a/.github/workflows/integration-test-gha.yaml b/.github/workflows/integration-test-gha.yaml index 8161dcb22..98a5dbe86 100644 --- a/.github/workflows/integration-test-gha.yaml +++ b/.github/workflows/integration-test-gha.yaml @@ -39,7 +39,16 @@ jobs: uses: mikefarah/yq@0b34c9a00de1c575a34eea05af1d956a525c4fc1 # v4.34.2 with: cmd: yq eval -i '(.images.[] | select(.name == "ghcr.io/sillsdev/lexbox-api").newTag) = "${{ inputs.lexbox-api-tag }}"' "./deployment/gha/kustomization.yaml" - # It's also possible that hgweb and/or ui image may have changed; if so, pull them and update kustomization.yaml for them as well + # It's also possible that hgweb, fw-headless, and/or ui image may have changed; if so, pull them and update kustomization.yaml for them as well + - name: Pull fw-headless if updated + id: fw-headless_image + continue-on-error: true + run: docker pull ghcr.io/sillsdev/lexbox-fw-headless:${{ inputs.lexbox-api-tag }} + - name: Update image fw-headless version + if: ${{ steps.fw-headless_image.outcome == 'success' }} + uses: mikefarah/yq@0b34c9a00de1c575a34eea05af1d956a525c4fc1 # v4.34.2 + with: + cmd: yq eval -i '(.images.[] | select(.name == "ghcr.io/sillsdev/lexbox-fw-headless").newTag) = "${{ inputs.lexbox-api-tag }}"' "./deployment/gha/kustomization.yaml" - name: Pull hgweb if updated id: hgweb_image continue-on-error: true diff --git a/.github/workflows/lexbox-fw-headless.yaml b/.github/workflows/lexbox-fw-headless.yaml new file mode 100644 index 000000000..3d49847ea --- /dev/null +++ b/.github/workflows/lexbox-fw-headless.yaml @@ -0,0 +1,99 @@ +name: Build FwHeadless + +# https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#on +on: + workflow_call: + inputs: + version: + description: 'The version of the image to build' + required: true + type: string + label-latest: + description: 'The label to apply to the latest image' + type: boolean + default: false + +env: + IMAGE_NAME: ghcr.io/sillsdev/lexbox-fw-headless + + +jobs: + publish-fw-headless: + timeout-minutes: 60 + runs-on: ubuntu-latest + + # postgres db is for automated tests + # services: + # postgres: + # image: postgres:15-alpine + # env: + # POSTGRES_PASSWORD: 972b722e63f549938d07bd8c4ee5086c + # POSTGRES_DB: lexbox-tests + # # Set health checks to wait until postgres has started + # options: >- + # --health-cmd pg_isready + # --health-interval 10s + # --health-timeout 5s + # --health-retries 5 + # ports: + # # Maps tcp port 5432 on service container to the host + # - 5433:5432 + + env: + # https://docs.docker.com/develop/develop-images/build_enhancements/ + DOCKER_BUILDKIT: 1 + + steps: + - uses: actions/checkout@v4 + with: + submodules: true + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '9.x' + - name: Dotnet build + run: dotnet build backend/FwHeadless/FwHeadless.csproj + # TODO: Write FwHeadless unit tests, probably based on existing sync tests + # - name: Unit tests + # run: dotnet test backend/FwHeadless/FwHeadless.csproj --logger:"xunit;LogFileName={assembly}.results.xml" --results-directory ./test-results --filter "Category!=Integration&Category!=FlakyIntegration" --blame-hang-timeout 10m + # - name: Publish unit test results + # uses: EnricoMi/publish-unit-test-result-action@8885e273a4343cd7b48eaa72428dea0c3067ea98 # v2.14.0 + # if: always() + # with: + # check_name: C# Unit Tests + # files: ./test-results/*.xml + # - name: Upload test results + # if: always() + # uses: actions/upload-artifact@v4 + # with: + # name: dotnet-unit-test-results + # path: ./test-results + + - name: Docker meta + id: meta + if: ${{ !env.ACT }} + uses: docker/metadata-action@8e5442c4ef9f78752691e2d8f8d19755c6f78e81 # v5.5.1 + with: + images: ${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=ref,event=pr + type=raw,enable=${{ inputs.label-latest }},value=latest + type=raw,value=${{ inputs.version }} + + - name: ghcr.io login + uses: docker/login-action@e92390c5fb421da1463c202d546fed0ec5c39f20 # v3.1.0 + if: ${{ !env.ACT }} + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - uses: docker/build-push-action@2cdde995de11925a030ce8070c3d77a52ffcf1c0 # v5.3.0 + with: + context: backend + file: backend/FwHeadless/Dockerfile + build-args: | + APP_VERSION=${{ inputs.version }} + push: ${{ !env.ACT && github.repository == 'sillsdev/languageforge-lexbox' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/release-pipeline.yaml b/.github/workflows/release-pipeline.yaml index 92450db98..006e32548 100644 --- a/.github/workflows/release-pipeline.yaml +++ b/.github/workflows/release-pipeline.yaml @@ -43,10 +43,18 @@ jobs: version: ${{ needs.set-version.outputs.version }} label-latest: true + build-fw-headless: + name: Build fw-headless + needs: [ set-version ] + uses: ./.github/workflows/lexbox-fw-headless.yaml + with: + version: ${{ needs.set-version.outputs.version }} + label-latest: true + deploy: name: Deploy Staging uses: ./.github/workflows/deploy.yaml - needs: [ build-api, build-ui, build-hgweb, set-version ] + needs: [ build-api, build-ui, build-hgweb, build-fw-headless, set-version ] secrets: inherit with: version: ${{ needs.set-version.outputs.version }} diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 13510c966..7f6e14c33 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -8,6 +8,7 @@ "redhat.vscode-yaml", "task.vscode-task", "dbaeumer.vscode-eslint", - "katjanakosic.vscode-json5" + "katjanakosic.vscode-json5", + "tilt-dev.tiltfile" ] } diff --git a/LexBox.sln b/LexBox.sln index 22a2bad17..612c0e95e 100644 --- a/LexBox.sln +++ b/LexBox.sln @@ -51,7 +51,7 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LcmDebugger", "backend\LfNe EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MiniLcm.Tests", "backend\FwLite\MiniLcm.Tests\MiniLcm.Tests.csproj", "{00AE5440-0E36-4488-935B-5B11301BA57D}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CrdtMerge", "backend\CrdtMerge\CrdtMerge.csproj", "{ECBA46AB-AF87-4D4D-9716-FD77264B817F}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FwHeadless", "backend\FwHeadless\FwHeadless.csproj", "{ECBA46AB-AF87-4D4D-9716-FD77264B817F}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution diff --git a/README.md b/README.md index 598419a5f..5e48f2ecc 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,6 @@ files related to a specific service should be in a folder named after the servic There are some exceptions: * `LexBox.sln` visual studio expects the sln to be at the root of the repo and can make things difficult otherwise -Other files, like `skaffold.yaml`, should be at the root of the repo, because they're related to all services. - ## Development ### Prerequisites @@ -29,11 +27,10 @@ Other files, like `skaffold.yaml`, should be at the root of the repo, because th * linux: `sudo snap install task --classic` or other options on their website * mac: `brew install go-task/tap/go-task` * via npm: `npm install -g @go-task/cli` - * install [Skaffold](https://skaffold.dev/docs/install/#standalone-binary) and add it to your path - * the file you will download is **NOT** an installer, just a standalone runnable .exe (on Windows) or binary (on Linux) + * install [Tilt](https://docs.tilt.dev/) and add it to your path * on Linux, a good practice is to create `$HOME/.local/bin` and put binaries there; most distributions automatically add `$HOME/.local/bin` to your path if it exists - * don't forget to run `chmod +x $HOME/.local/bin/skaffold` - * on Windows, we suggest creating a `bin` folder in your home folder. Put the Skaffold binary there, then do the following: + * don't forget to run `chmod +x $HOME/.local/bin/tilt` + * on Windows, we suggest creating a `bin` folder in your home folder. Put the Tilt binary there, then do the following: * go to your System properties, click the **Advanced** tab, and click **Environment Variables...** * Click the Path variable (in either User or System, User is recommended) and click the **Edit...** button * Add `C:\Users\YOUR_USER_NAME\bin` to the list and click **OK** diff --git a/Taskfile.yml b/Taskfile.yml index ca2e265ab..933eeff18 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -64,18 +64,15 @@ tasks: # k8s up: interactive: true + aliases: [backend-up] cmds: - - skaffold dev --cleanup=false --kube-context docker-desktop + - tilt up # dev infra-up: - desc: 'Starts infrastructure for our ui and api, if you want port forwarding use k8s:infra-forward' + desc: 'Starts infrastructure for our ui and api, does not forward ports for api, if you want port forwarding use k8s:infra-forward' cmds: - - skaffold run --cleanup=false --kube-context docker-desktop --profile=infra - backend-up: - desc: Starts entire backend for our ui (including the LexBox api) - cmds: - - skaffold dev --cleanup=false --kube-context docker-desktop --profile=no-frontend + - tilt up -- --lexbox-api-local ui-dev: aliases: [ ui ] diff --git a/Tiltfile b/Tiltfile new file mode 100644 index 000000000..0584c454a --- /dev/null +++ b/Tiltfile @@ -0,0 +1,110 @@ +#!python +# version_settings() enforces a minimum Tilt version +# https://docs.tilt.dev/api.html#api.version_settings +version_settings(constraint='>=0.33.20') +secret_settings(disable_scrub=True) +config.define_bool("lexbox-api-local") +cfg = config.parse() +forward_lexbox = not cfg.get("lexbox-api-local", False) + +docker_build( + 'local-dev-init', + 'data' +) + +docker_build( + 'ghcr.io/sillsdev/lexbox-api', + context='backend', + dockerfile='./backend/LexBoxApi/dev.Dockerfile', + only=['.'], + ignore=['FwHeadless'], + live_update=[ + sync('backend', '/src/backend') + ] +) + +docker_build( + 'ghcr.io/sillsdev/lexbox-fw-headless', + context='backend', + dockerfile='./backend/FwHeadless/dev.Dockerfile', + only=['.'], + ignore=['LexBoxApi'], + live_update=[ + sync('backend', '/src/backend') + ] +) + +docker_build( + 'ghcr.io/sillsdev/lexbox-ui', + context='frontend', + dockerfile='./frontend/dev.Dockerfile', + only=['.'], + live_update=[ + sync('frontend', '/app'), + ] +) + +docker_build( + 'ghcr.io/sillsdev/lexbox-hgweb', + context='hgweb', + dockerfile='./hgweb/Dockerfile', + only=['.'], + build_args={"APP_VERSION": "dockerDev"}, + live_update=[ + sync('hgweb/repos', '/var/hg/repos'), + ] +) + +k8s_yaml(kustomize('./deployment/local-dev')) +allow_k8s_contexts('docker-desktop') + +lexbox_ports = [ + port_forward(1080, name='maildev'), + port_forward(18888, name='aspire'), + port_forward(4318, name='otel') #otel +] +if forward_lexbox: + lexbox_ports = [port_forward(5158, name='lexbox-api', link_path='/api/swagger')] + lexbox_ports + +k8s_resource( + 'lexbox', + labels=['app'], + resource_deps=['db'], + port_forwards=lexbox_ports +) +k8s_resource( + 'ui', + labels=['app'], + links=[link('http://localhost', 'ui')] +) +k8s_resource( + 'fw-headless', + labels=['app'], + resource_deps=['db'], + port_forwards=[ + port_forward(5275, 80, name='fw-headless') + ] +) +k8s_resource( + 'hg', + labels=['app'], + port_forwards=[ + port_forward(8088, name='hg'), + port_forward(8034, 80, name='hg-resumable'), + ] +) +k8s_resource( + 'db', + port_forwards=[ + port_forward(5433, 5432, name='db'), + ], + labels=["db"] +) +k8s_resource( + 'pgadmin', + resource_deps=['db'], + port_forwards=[ + port_forward(4810, name='pgadmin'), + ], + labels=['db'] +) diff --git a/backend/.dockerignore b/backend/.dockerignore index 6ee9e3764..c2f36060e 100644 --- a/backend/.dockerignore +++ b/backend/.dockerignore @@ -2,3 +2,9 @@ **/obj *Dockerfile Testing +**/artifacts/publish +CrdtMerge/Mercurial +CrdtMerge/MercurialExtensions +*.sqlite +*.sqlite-shm +*.sqlite-wal diff --git a/backend/FwHeadless/Dockerfile b/backend/FwHeadless/Dockerfile new file mode 100644 index 000000000..022c350c4 --- /dev/null +++ b/backend/FwHeadless/Dockerfile @@ -0,0 +1,34 @@ +# syntax=docker/dockerfile:1 +FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base +WORKDIR /app +EXPOSE 80 +EXPOSE 443 + +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build + +COPY . . +RUN --mount=type=cache,target=/root/.nuget/packages dotnet restore "FwHeadless/FwHeadless.csproj" + +ARG APP_VERSION +LABEL version=$APP_VERSION + +RUN --mount=type=cache,target=/root/.nuget/packages dotnet build /p:InformationalVersion=$APP_VERSION "FwHeadless/FwHeadless.csproj" -c Release -o /app/build + +FROM build AS publish +RUN --mount=type=cache,target=/root/.nuget/packages dotnet publish /p:InformationalVersion=$APP_VERSION "FwHeadless/FwHeadless.csproj" -c Release -o /app/publish + +FROM base AS final +RUN mkdir -p /var/lib/fw-headless /var/www/.local/share && chown -R www-data:www-data /var/lib/fw-headless /var/www/.local/share +RUN apt-get update \ + && apt-get install --yes --no-install-recommends tini iputils-ping python3 \ + && rm -rf /var/lib/apt/lists/* +WORKDIR /app +COPY --from=publish /app/publish . +# Ensure Mercurial exec bit was not stripped by dotnet CLI tools +RUN chmod +x Mercurial/hg && chmod +x Mercurial/chg 2>/dev/null || true +# Fix up mercurial.ini path to fixutf8 +RUN sed -i -e 's/fixutf8 = \/FwHeadless/fixutf8 = \/app/' Mercurial/mercurial.ini +USER www-data:www-data +ENV XDG_DATA_HOME=/var/www/.local/share +ENTRYPOINT ["tini", "--"] +CMD ["dotnet", "FwHeadless.dll"] diff --git a/backend/CrdtMerge/CrdtMerge.csproj b/backend/FwHeadless/FwHeadless.csproj similarity index 95% rename from backend/CrdtMerge/CrdtMerge.csproj rename to backend/FwHeadless/FwHeadless.csproj index 0380eb189..335b1e00b 100644 --- a/backend/CrdtMerge/CrdtMerge.csproj +++ b/backend/FwHeadless/FwHeadless.csproj @@ -24,7 +24,7 @@ - - + + diff --git a/backend/CrdtMerge/CrdtMergeConfig.cs b/backend/FwHeadless/FwHeadlessConfig.cs similarity index 90% rename from backend/CrdtMerge/CrdtMergeConfig.cs rename to backend/FwHeadless/FwHeadlessConfig.cs index 8d21a36b1..ec664817e 100644 --- a/backend/CrdtMerge/CrdtMergeConfig.cs +++ b/backend/FwHeadless/FwHeadlessConfig.cs @@ -1,8 +1,8 @@ using System.ComponentModel.DataAnnotations; -namespace CrdtMerge; +namespace FwHeadless; -public class CrdtMergeConfig +public class FwHeadlessConfig { [Required, Url, RegularExpression(@"^.+/$", ErrorMessage = "Must end with '/'")] public required string LexboxUrl { get; init; } diff --git a/backend/CrdtMerge/CrdtMergeKernel.cs b/backend/FwHeadless/FwHeadlessKernel.cs similarity index 69% rename from backend/CrdtMerge/CrdtMergeKernel.cs rename to backend/FwHeadless/FwHeadlessKernel.cs index 0c312c074..9a0d42b95 100644 --- a/backend/CrdtMerge/CrdtMergeKernel.cs +++ b/backend/FwHeadless/FwHeadlessKernel.cs @@ -2,16 +2,16 @@ using FwLiteProjectSync; using LcmCrdt; -namespace CrdtMerge; +namespace FwHeadless; -public static class CrdtMergeKernel +public static class FwHeadlessKernel { - public static void AddCrdtMerge(this IServiceCollection services) + public static void AddFwHeadless(this IServiceCollection services) { services .AddLogging(builder => builder.AddConsole().AddDebug().AddFilter("Microsoft.EntityFrameworkCore", LogLevel.Warning)); - services.AddOptions() - .BindConfiguration("SendReceiveConfig") + services.AddOptions() + .BindConfiguration("FwHeadlessConfig") .ValidateDataAnnotations() .ValidateOnStart(); services.AddScoped(); diff --git a/backend/CrdtMerge/Program.cs b/backend/FwHeadless/Program.cs similarity index 95% rename from backend/CrdtMerge/Program.cs rename to backend/FwHeadless/Program.cs index e2604a9ec..261dedd9b 100644 --- a/backend/CrdtMerge/Program.cs +++ b/backend/FwHeadless/Program.cs @@ -1,4 +1,4 @@ -using CrdtMerge; +using FwHeadless; using FwDataMiniLcmBridge; using FwLiteProjectSync; using LcmCrdt; @@ -14,12 +14,14 @@ // Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi builder.Services.AddOpenApi(); +builder.Services.AddHealthChecks(); + builder.Services.AddLexData( autoApplyMigrations: false, useOpenIddict: false ); -builder.Services.AddCrdtMerge(); +builder.Services.AddFwHeadless(); var app = builder.Build(); @@ -33,6 +35,8 @@ app.UseHttpsRedirection(); +app.MapHealthChecks("/api/healthz"); + app.MapPost("/sync", ExecuteMergeRequest); app.Run(); @@ -41,7 +45,7 @@ ILogger logger, IServiceProvider services, SendReceiveService srService, - IOptions config, + IOptions config, FwDataFactory fwDataFactory, ProjectsService projectsService, ProjectLookupService projectLookupService, diff --git a/backend/CrdtMerge/ProjectLookupService.cs b/backend/FwHeadless/ProjectLookupService.cs similarity index 94% rename from backend/CrdtMerge/ProjectLookupService.cs rename to backend/FwHeadless/ProjectLookupService.cs index ac8244e99..9cb8cb971 100644 --- a/backend/CrdtMerge/ProjectLookupService.cs +++ b/backend/FwHeadless/ProjectLookupService.cs @@ -1,7 +1,7 @@ using LexData; using Microsoft.EntityFrameworkCore; -namespace CrdtMerge; +namespace FwHeadless; public class ProjectLookupService(LexBoxDbContext dbContext) { diff --git a/backend/CrdtMerge/Properties/launchSettings.json b/backend/FwHeadless/Properties/launchSettings.json similarity index 100% rename from backend/CrdtMerge/Properties/launchSettings.json rename to backend/FwHeadless/Properties/launchSettings.json diff --git a/backend/CrdtMerge/SendReceiveHelpers.cs b/backend/FwHeadless/SendReceiveHelpers.cs similarity index 96% rename from backend/CrdtMerge/SendReceiveHelpers.cs rename to backend/FwHeadless/SendReceiveHelpers.cs index 8eb1404c0..9c8ac9494 100644 --- a/backend/CrdtMerge/SendReceiveHelpers.cs +++ b/backend/FwHeadless/SendReceiveHelpers.cs @@ -1,7 +1,7 @@ using FwDataMiniLcmBridge; using SIL.Progress; -namespace CrdtMerge; +namespace FwHeadless; public static class SendReceiveHelpers { @@ -12,7 +12,7 @@ public record ProjectPath(string Code, string Dir) public record SendReceiveAuth(string Username, string Password) { - public SendReceiveAuth(CrdtMergeConfig config) : this(config.LexboxUsername, config.LexboxPassword) { } + public SendReceiveAuth(FwHeadlessConfig config) : this(config.LexboxUsername, config.LexboxPassword) { } }; public record LfMergeBridgeResult(string Output, string ProgressMessages); diff --git a/backend/CrdtMerge/SendReceiveService.cs b/backend/FwHeadless/SendReceiveService.cs similarity index 91% rename from backend/CrdtMerge/SendReceiveService.cs rename to backend/FwHeadless/SendReceiveService.cs index 14db577ae..37023dc8f 100644 --- a/backend/CrdtMerge/SendReceiveService.cs +++ b/backend/FwHeadless/SendReceiveService.cs @@ -1,9 +1,9 @@ using FwDataMiniLcmBridge; using Microsoft.Extensions.Options; -namespace CrdtMerge; +namespace FwHeadless; -public class SendReceiveService(IOptions config) +public class SendReceiveService(IOptions config) { public SendReceiveHelpers.LfMergeBridgeResult SendReceive(FwDataProject project, string? projectCode, string? commitMessage = null) { diff --git a/backend/CrdtMerge/appsettings.Development.json b/backend/FwHeadless/appsettings.Development.json similarity index 95% rename from backend/CrdtMerge/appsettings.Development.json rename to backend/FwHeadless/appsettings.Development.json index 0995c2046..22a96c2a5 100644 --- a/backend/CrdtMerge/appsettings.Development.json +++ b/backend/FwHeadless/appsettings.Development.json @@ -1,5 +1,5 @@ { - "SendReceiveConfig": { + "FwHeadlessConfig": { "ProjectStorageRoot": "../../hgweb/repos", "LexboxUrl": "http://localhost/", "LexboxUsername": "admin", diff --git a/backend/CrdtMerge/appsettings.json b/backend/FwHeadless/appsettings.json similarity index 87% rename from backend/CrdtMerge/appsettings.json rename to backend/FwHeadless/appsettings.json index d766d8006..d56001975 100644 --- a/backend/CrdtMerge/appsettings.json +++ b/backend/FwHeadless/appsettings.json @@ -1,5 +1,5 @@ { - "CrdtMergeConfig": { + "FwHeadlessConfig": { "LexboxUsername": null }, "Logging": { diff --git a/backend/FwHeadless/dev.Dockerfile b/backend/FwHeadless/dev.Dockerfile new file mode 100644 index 000000000..db60a422c --- /dev/null +++ b/backend/FwHeadless/dev.Dockerfile @@ -0,0 +1,48 @@ +# syntax=docker/dockerfile:1 +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build +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 tini iputils-ping python3 +RUN mkdir -p /var/lib/fw-headless /var/www/.local/share && chown -R www-data:www-data /var/lib/fw-headless /var/www/ +USER www-data:www-data +WORKDIR /src/backend +# Uncomment line below if second COPY fails +# RUN mkdir -p FwLite && chown www-data:www-data FwLite +# Copy the main source project files +COPY --chown=www-data:www-data *.sln FwHeadless/FwHeadless.csproj FixFwData/FixFwData.csproj LexCore/LexCore.csproj LexData/LexData.csproj ./ +# move them into the proper sub folders, based on the name of the project +RUN for file in $(ls *.csproj); do dir=${file%.*}; mkdir -p ${dir}/ && mv -v $file ${dir}/; done +# Do the same for csproj files in slightly different hierarchies +COPY --chown=www-data:www-data harmony/src/*/*.csproj ./ +RUN for file in $(ls *.csproj); do dir=${file%.*}; mkdir -p harmony/src/${dir}/ && mv -v $file harmony/src/${dir}/; done +COPY --chown=www-data:www-data harmony/src/Directory.Build.props ./harmony/src/ +COPY --chown=www-data:www-data FwLite/FwDataMiniLcmBridge/FwDataMiniLcmBridge.csproj FwLite/LcmCrdt/LcmCrdt.csproj FwLite/MiniLcm/MiniLcm.csproj FwLite/FwLiteProjectSync/FwLiteProjectSync.csproj ./ +RUN for file in $(ls *.csproj); do dir=${file%.*}; mkdir -p FwLite/${dir}/ && mv -v $file FwLite/${dir}/; done + +ARG CACHE_LOCATION=/src/dotnet-cache +RUN --mount=type=cache,target=$CACHE_LOCATION,uid=33,gid=33 \ +cp -r $CACHE_LOCATION/.local $CACHE_LOCATION/.nuget /var/www/ || true + +# Now that all csproj files are in place, restore them +RUN dotnet restore FwHeadless/FwHeadless.csproj + +#the cache needs to be stored in the image, +#so we can't use the cache on the restore command, so we back it up to the cache here + +RUN --mount=type=cache,target=$CACHE_LOCATION,uid=33,gid=33 \ + cp -r /var/www/.local /var/www/.nuget $CACHE_LOCATION/ + +COPY --chown=www-data:www-data . . +WORKDIR /src/backend/FwHeadless +#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 + +#ensures the shutdown happens quickly +ENTRYPOINT ["tini", "--"] + +ENV ASPNETCORE_ENVIRONMENT=Development +# no need to restore because we already restored as part of building the image +CMD dotnet watch run --property:InformationalVersion=dockerDev --no-restore --non-interactive diff --git a/backend/LexBoxApi/GraphQL/CustomTypes/IsLanguageForgeProjectDataLoader.cs b/backend/LexBoxApi/GraphQL/CustomTypes/IsLanguageForgeProjectDataLoader.cs index a86c8440b..0489e7243 100644 --- a/backend/LexBoxApi/GraphQL/CustomTypes/IsLanguageForgeProjectDataLoader.cs +++ b/backend/LexBoxApi/GraphQL/CustomTypes/IsLanguageForgeProjectDataLoader.cs @@ -20,7 +20,7 @@ public IsLanguageForgeProjectDataLoader( IBatchScheduler batchScheduler, [FromKeyedServices(ResiliencePolicyName)] ResiliencePipeline> resiliencePipeline, - DataLoaderOptions? options = null) + DataLoaderOptions options) : base(batchScheduler, options) { _resiliencePipeline = resiliencePipeline; diff --git a/backend/LexBoxApi/GraphQL/GraphQlSetupKernel.cs b/backend/LexBoxApi/GraphQL/GraphQlSetupKernel.cs index 33b75e1f5..abd27c078 100644 --- a/backend/LexBoxApi/GraphQL/GraphQlSetupKernel.cs +++ b/backend/LexBoxApi/GraphQL/GraphQlSetupKernel.cs @@ -1,10 +1,7 @@ using DataAnnotatedModelValidations; using HotChocolate.Diagnostics; -using LexBoxApi.Auth; using LexBoxApi.GraphQL.CustomFilters; using LexBoxApi.Services; -using LexBoxApi.Services.Email; -using LexCore.ServiceInterfaces; using LexData; namespace LexBoxApi.GraphQL; @@ -18,15 +15,14 @@ public static void AddLexGraphQL(this IServiceCollection services, IHostEnvironm if (forceGenerateSchema || env.IsDevelopment()) services.AddHostedService(); - services.AddGraphQLServer() + services + .AddGraphQLServer() + .ModifyCostOptions(options => + { + // See: https://github.com/sillsdev/languageforge-lexbox/issues/1179 + options.EnforceCostLimits = false; + }) .InitializeOnStartup() - .RegisterDbContext() - .RegisterService() - .RegisterService() - .RegisterService() - .RegisterService() - .RegisterService() - .RegisterService() .AddDataAnnotationsValidator() .AddSorting(descriptor => { @@ -39,11 +35,11 @@ public static void AddLexGraphQL(this IServiceCollection services, IHostEnvironm descriptor.AddDeterministicInvariantContainsFilter(); }) .AddProjections() - .SetPagingOptions(new() + .ModifyPagingOptions(options => { - DefaultPageSize = 100, - MaxPageSize = 1000, - IncludeTotalCount = true + options.DefaultPageSize = 100; + options.MaxPageSize = 1000; + options.IncludeTotalCount = true; }) .AddAuthorization() .AddLexBoxApiTypes() diff --git a/backend/LexBoxApi/GraphQL/LexQueries.cs b/backend/LexBoxApi/GraphQL/LexQueries.cs index bd70ef7af..442fcd8f3 100644 --- a/backend/LexBoxApi/GraphQL/LexQueries.cs +++ b/backend/LexBoxApi/GraphQL/LexQueries.cs @@ -162,7 +162,11 @@ public IQueryable UsersInMyOrg(LexBoxDbContext context, LoggedInContext lo IPermissionService permissionService, IResolverContext context) { - var org = await dbContext.Orgs.Where(o => o.Id == orgId).AsNoTracking().Project(context).SingleOrDefaultAsync(); + //todo remove this workaround once the issue is fixed + var projectContext = + context.GetLocalStateOrDefault("HotChocolate.Data.Projections.ProxyContext") ?? + context; + var org = await dbContext.Orgs.Where(o => o.Id == orgId).AsNoTracking().Project(projectContext).SingleOrDefaultAsync(); if (org is null) return org; // Site admins and org admins can see everything if (permissionService.CanEditOrg(orgId)) return org; diff --git a/backend/LexBoxApi/GraphQL/OrgMutations.cs b/backend/LexBoxApi/GraphQL/OrgMutations.cs index df62a3995..54c71f6ce 100644 --- a/backend/LexBoxApi/GraphQL/OrgMutations.cs +++ b/backend/LexBoxApi/GraphQL/OrgMutations.cs @@ -21,6 +21,7 @@ public class OrgMutations [UseMutationConvention] [UseFirstOrDefault] [UseProjection] + [RefreshJwt] public async Task> CreateOrganization(string name, LexBoxDbContext dbContext, LoggedInContext loggedInContext, @@ -65,7 +66,7 @@ public async Task DeleteOrg(Guid orgId, public async Task> AddProjectToOrg( LexBoxDbContext dbContext, IPermissionService permissionService, - [Service] ProjectService projectService, + ProjectService projectService, Guid orgId, Guid projectId) { @@ -99,7 +100,7 @@ public async Task> AddProjectToOrg( public async Task AddProjectsToOrg( LexBoxDbContext dbContext, IPermissionService permissionService, - [Service] ProjectService projectService, + ProjectService projectService, IResolverContext resolverContext, Guid orgId, Guid[] projectIds) @@ -138,18 +139,17 @@ public async Task> AddProjectToOrg( public async Task> RemoveProjectFromOrg( LexBoxDbContext dbContext, IPermissionService permissionService, - [Service] ProjectService projectService, + ProjectService projectService, Guid orgId, Guid projectId) { var org = await dbContext.Orgs.Include(o => o.Members).SingleOrDefaultAsync(o => o.Id == orgId); NotFoundException.ThrowIfNull(org); - permissionService.AssertCanAddProjectToOrg(org); var project = await dbContext.Projects.Where(p => p.Id == projectId) .Include(p => p.Organizations) .SingleOrDefaultAsync(); NotFoundException.ThrowIfNull(project); - await permissionService.AssertCanManageProject(projectId); + await permissionService.AssertCanRemoveProjectFromOrg(org, projectId); var foundOrg = project.Organizations.FirstOrDefault(o => o.Id == orgId); if (foundOrg is not null) { @@ -185,7 +185,7 @@ public async Task> SetOrgMemberRole( OrgRole role, string emailOrUsername, bool canInvite, - [Service] IEmailService emailService) + IEmailService emailService) { var org = await dbContext.Orgs.FindAsync(orgId); NotFoundException.ThrowIfNull(org); @@ -250,6 +250,30 @@ public async Task> ChangeOrgMemberRole( return dbContext.Orgs.Where(o => o.Id == orgId); } + [Error] + [Error] + [UseMutationConvention] + [RefreshJwt] + public async Task LeaveOrg( + Guid orgId, + LoggedInContext loggedInContext, + LexBoxDbContext dbContext) + { + var org = await dbContext.Orgs.Where(p => p.Id == orgId) + .Include(p => p.Members) + .SingleOrDefaultAsync(); + NotFoundException.ThrowIfNull(org); + var member = org.Members.FirstOrDefault(u => u.UserId == loggedInContext.User.Id); + if (member is null) return org; + if (member.Role == OrgRole.Admin && org.Members.Count(m => m.Role == OrgRole.Admin) == 1) + { + throw new LastMemberCantLeaveException(); + } + org.Members.Remove(member); + await dbContext.SaveChangesAsync(); + return org; + } + private async Task UpdateOrgMemberRole(LexBoxDbContext dbContext, Organization org, OrgRole? role, Guid userId) { var member = org.Members.FirstOrDefault(m => m.UserId == userId); diff --git a/backend/LexBoxApi/GraphQL/ProjectMutations.cs b/backend/LexBoxApi/GraphQL/ProjectMutations.cs index 3ceeeb163..ccdb9046e 100644 --- a/backend/LexBoxApi/GraphQL/ProjectMutations.cs +++ b/backend/LexBoxApi/GraphQL/ProjectMutations.cs @@ -37,8 +37,8 @@ public record CreateProjectResponse(Guid? Id, CreateProjectResult Result); LoggedInContext loggedInContext, IPermissionService permissionService, CreateProjectInput input, - [Service] ProjectService projectService, - [Service] IEmailService emailService) + ProjectService projectService, + IEmailService emailService) { if (!loggedInContext.User.IsAdmin) { @@ -73,7 +73,7 @@ public async Task> AddProjectMember( LoggedInContext loggedInContext, AddProjectMemberInput input, LexBoxDbContext dbContext, - [Service] IEmailService emailService) + IEmailService emailService) { await permissionService.AssertCanManageProject(input.ProjectId); var project = await dbContext.Projects.FindAsync(input.ProjectId); @@ -248,7 +248,7 @@ public async Task> AskToJoinProject( LoggedInContext loggedInContext, Guid projectId, LexBoxDbContext dbContext, - [Service] IEmailService emailService) + IEmailService emailService) { await permissionService.AssertCanAskToJoinProject(projectId); @@ -320,7 +320,7 @@ public async Task> ChangeProjectDescription(ChangeProjectDes [UseProjection] public async Task> SetProjectConfidentiality(SetProjectConfidentialityInput input, IPermissionService permissionService, - [Service] ProjectService projectService, + ProjectService projectService, LexBoxDbContext dbContext) { await permissionService.AssertCanManageProject(input.ProjectId); @@ -342,7 +342,7 @@ public async Task> SetProjectConfidentiality(SetProjectConfi public async Task> SetRetentionPolicy( SetRetentionPolicyInput input, IPermissionService permissionService, - [Service] ProjectService projectService, + ProjectService projectService, LexBoxDbContext dbContext) { await permissionService.AssertCanManageProject(input.ProjectId); @@ -363,7 +363,7 @@ public async Task> SetRetentionPolicy( [UseProjection] public async Task> UpdateProjectLexEntryCount(string code, IPermissionService permissionService, - [Service] ProjectService projectService, + ProjectService projectService, LexBoxDbContext dbContext) { var projectId = await projectService.LookupProjectId(code); @@ -382,7 +382,7 @@ public async Task> UpdateProjectLexEntryCount(string code, [UseProjection] public async Task> UpdateProjectLanguageList(string code, IPermissionService permissionService, - [Service] ProjectService projectService, + ProjectService projectService, LexBoxDbContext dbContext) { var projectId = await projectService.LookupProjectId(code); @@ -401,7 +401,7 @@ public async Task> UpdateProjectLanguageList(string code, [UseProjection] public async Task> UpdateLangProjectId(string code, IPermissionService permissionService, - [Service] ProjectService projectService, + ProjectService projectService, LexBoxDbContext dbContext) { var projectId = await projectService.LookupProjectId(code); @@ -420,7 +420,7 @@ public async Task> UpdateLangProjectId(string code, [UseProjection] public async Task> UpdateFLExModelVersion(string code, IPermissionService permissionService, - [Service] ProjectService projectService, + ProjectService projectService, LexBoxDbContext dbContext) { var projectId = await projectService.LookupProjectId(code); @@ -499,7 +499,7 @@ public async Task DeleteDraftProject( public async Task> SoftDeleteProject( Guid projectId, IPermissionService permissionService, - [Service] ProjectService projectService, + ProjectService projectService, LexBoxDbContext dbContext, IHgService hgService) { diff --git a/backend/LexBoxApi/LexBoxApi.csproj b/backend/LexBoxApi/LexBoxApi.csproj index e1d319dc9..baffc9fb9 100644 --- a/backend/LexBoxApi/LexBoxApi.csproj +++ b/backend/LexBoxApi/LexBoxApi.csproj @@ -14,16 +14,16 @@ - - - + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - - - - + + + + diff --git a/backend/LexBoxApi/Program.cs b/backend/LexBoxApi/Program.cs index 904dee824..a25d36415 100644 --- a/backend/LexBoxApi/Program.cs +++ b/backend/LexBoxApi/Program.cs @@ -3,6 +3,7 @@ using System.Text.Json.Serialization; using AppAny.Quartz.EntityFrameworkCore.Migrations; using AppAny.Quartz.EntityFrameworkCore.Migrations.PostgreSQL; +using HotChocolate.AspNetCore; using LexBoxApi; using LexBoxApi.Auth; using LexBoxApi.Auth.Attributes; @@ -167,7 +168,7 @@ app.UseAuthentication(); app.UseAuthorization(); app.MapSecurityTxt(); -app.MapBananaCakePop("/api/graphql/ui").AllowAnonymous(); +app.MapNitroApp("/api/graphql/ui").WithOptions(new (){ServeMode = GraphQLToolServeMode.Embedded}).AllowAnonymous(); if (app.Environment.IsDevelopment()) //required for vite to generate types app.MapGraphQLSchema("/api/graphql/schema.graphql").AllowAnonymous(); diff --git a/backend/LexBoxApi/Services/DevGqlSchemaWriterService.cs b/backend/LexBoxApi/Services/DevGqlSchemaWriterService.cs index 712286921..ec291a45a 100644 --- a/backend/LexBoxApi/Services/DevGqlSchemaWriterService.cs +++ b/backend/LexBoxApi/Services/DevGqlSchemaWriterService.cs @@ -1,5 +1,10 @@ using HotChocolate.Execution; +using LexBoxApi.Auth; using LexBoxApi.GraphQL; +using LexBoxApi.GraphQL.CustomTypes; +using LexBoxApi.Services.Email; +using LexCore.ServiceInterfaces; +using LexData; using Microsoft.Extensions.Hosting.Internal; namespace LexBoxApi.Services; @@ -18,9 +23,17 @@ public static bool IsSchemaGenerationRequest(string[] args) public static async Task GenerateGqlSchema(string[] args) { var builder = Host.CreateApplicationBuilder(args); - builder.Services.AddLogging(); - builder.Services.AddSingleton(); - builder.Services.AddLexGraphQL(builder.Environment, true); + builder.Services + .AddLogging() + .AddSingleton() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped((services) => new LoggedInContext(null!, null!)) + .AddScoped((services) => new LexBoxDbContext(null!, null!)) + .AddScoped() + .AddScoped() + .AddLexGraphQL(builder.Environment, true); var host = builder.Build(); await host.StartAsync(); await host.StopAsync(); diff --git a/backend/LexBoxApi/Services/PermissionService.cs b/backend/LexBoxApi/Services/PermissionService.cs index fcaec39e5..83b1d158c 100644 --- a/backend/LexBoxApi/Services/PermissionService.cs +++ b/backend/LexBoxApi/Services/PermissionService.cs @@ -196,10 +196,15 @@ public void AssertHasProjectRequestPermission() if (!HasProjectRequestPermission()) throw new UnauthorizedAccessException(); } + public bool CanCreateOrg() + { + return User is {Role: UserRole.admin}; + } + public void AssertCanCreateOrg() { //todo adjust permission - if (!HasProjectCreatePermission()) throw new UnauthorizedAccessException(); + if (!CanCreateOrg()) throw new UnauthorizedAccessException(); } public bool IsOrgMember(Guid orgId) @@ -234,4 +239,13 @@ public void AssertCanAddProjectToOrg(Organization org) if (org.Members.Any(m => m.UserId == User.Id)) return; throw new UnauthorizedAccessException(); } + + public async ValueTask AssertCanRemoveProjectFromOrg(Organization org, Guid projectId) + { + // Org managers can kick projects out and project managers can pull projects out + if (!CanEditOrg(org.Id) && !await CanManageProject(projectId)) + { + throw new UnauthorizedAccessException(); + } + } } diff --git a/backend/LexBoxApi/dev.Dockerfile b/backend/LexBoxApi/dev.Dockerfile index 8913eef77..bbedcbe09 100644 --- a/backend/LexBoxApi/dev.Dockerfile +++ b/backend/LexBoxApi/dev.Dockerfile @@ -18,9 +18,20 @@ RUN for file in $(ls *.csproj); do dir=${file%.*}; mkdir -p harmony/src/${dir}/ COPY harmony/src/Directory.Build.props ./harmony/src/ COPY FwLite/*/*.csproj ./ RUN for file in $(ls *.csproj); do dir=${file%.*}; mkdir -p FwLite/${dir}/ && mv -v $file FwLite/${dir}/; done + +ARG CACHE_LOCATION=/src/dotnet-cache +RUN --mount=type=cache,target=$CACHE_LOCATION,uid=33,gid=33 \ +cp -r $CACHE_LOCATION/.local $CACHE_LOCATION/.nuget /var/www/ || true + # Now that all csproj files are in place, restore them RUN dotnet restore FixFwData/FixFwData.csproj; dotnet restore LexBoxApi/LexBoxApi.csproj +#the cache needs to be stored in the image, +#so we can't use the cache on the restore command, so we back it up to the cache here + +RUN --mount=type=cache,target=$CACHE_LOCATION,uid=33,gid=33 \ + cp -r /var/www/.local /var/www/.nuget $CACHE_LOCATION/ + COPY --chown=www-data . . WORKDIR /src/backend/LexBoxApi #build here so that the build is run before container start, need to make sure the property is set both here @@ -34,4 +45,4 @@ ENTRYPOINT ["tini", "--"] # no need to restore because we already restored as part of building the image 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 +CMD dotnet watch run --property:InformationalVersion=dockerDev --no-restore --non-interactive diff --git a/backend/LexCore/Exceptions/LastMemberCantLeaveException.cs b/backend/LexCore/Exceptions/LastMemberCantLeaveException.cs index e5a509f6c..d050e09ca 100644 --- a/backend/LexCore/Exceptions/LastMemberCantLeaveException.cs +++ b/backend/LexCore/Exceptions/LastMemberCantLeaveException.cs @@ -2,7 +2,7 @@ public class LastMemberCantLeaveException : Exception { - public LastMemberCantLeaveException() : base("The last member of a project can't leave the project.") + public LastMemberCantLeaveException() : base("The last member can't leave.") { } } diff --git a/backend/LexCore/ServiceInterfaces/IPermissionService.cs b/backend/LexCore/ServiceInterfaces/IPermissionService.cs index dec18d5bd..13e488fc1 100644 --- a/backend/LexCore/ServiceInterfaces/IPermissionService.cs +++ b/backend/LexCore/ServiceInterfaces/IPermissionService.cs @@ -43,4 +43,5 @@ public interface IPermissionService void AssertCanEditOrg(Organization org); void AssertCanEditOrg(Guid orgId); void AssertCanAddProjectToOrg(Organization org); + ValueTask AssertCanRemoveProjectFromOrg(Organization org, Guid projectId); } diff --git a/backend/LexData/DbStartupService.cs b/backend/LexData/DbStartupService.cs index 9f6108362..c75abf841 100644 --- a/backend/LexData/DbStartupService.cs +++ b/backend/LexData/DbStartupService.cs @@ -96,6 +96,10 @@ private async Task TryMigrate(DbContext dbContext, CancellationToken cance { return false; } + catch (SocketException ex) when (ex.SocketErrorCode == SocketError.TryAgain) + { + return false; + } } public Task StopAsync(CancellationToken cancellationToken) diff --git a/backend/LexData/Entities/OrganizationEntityConfiguration.cs b/backend/LexData/Entities/OrganizationEntityConfiguration.cs index b8eda4636..07774376b 100644 --- a/backend/LexData/Entities/OrganizationEntityConfiguration.cs +++ b/backend/LexData/Entities/OrganizationEntityConfiguration.cs @@ -12,6 +12,7 @@ public override void Configure(EntityTypeBuilder builder) base.Configure(builder); builder.ToTable("Orgs"); builder.HasIndex(o => o.Name).IsUnique(); + builder.Property(u => u.Name).UseCollation(LexBoxDbContext.CaseInsensitiveCollation); builder.HasMany(o => o.Members) .WithOne(m => m.Organization) .HasForeignKey(m => m.OrgId) diff --git a/backend/LexData/Migrations/20241023132940_Make org names case insensitive.Designer.cs b/backend/LexData/Migrations/20241023132940_Make org names case insensitive.Designer.cs new file mode 100644 index 000000000..af4e8120d --- /dev/null +++ b/backend/LexData/Migrations/20241023132940_Make org names case insensitive.Designer.cs @@ -0,0 +1,1403 @@ +// +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("20241023132940_Make org names case insensitive")] + partial class Makeorgnamescaseinsensitive + { + /// + 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.8") + .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("OrgId") + .HasColumnType("uuid"); + + 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("FlexModelVersion") + .HasColumnType("integer"); + + b.Property("LangProjectId") + .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.OrgProjects", 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("ProjectId") + .HasColumnType("uuid"); + + b.Property("UpdatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.HasIndex("OrgId", "ProjectId") + .IsUnique(); + + b.ToTable("OrgProjects"); + }); + + 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") + .UseCollation("case_insensitive"); + + 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("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("SIL.Harmony.Core.ServerCommit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClientId") + .HasColumnType("uuid"); + + b.Property("Metadata") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.ComplexProperty>("HybridDateTime", "SIL.Harmony.Core.ServerCommit.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("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(); + + b.OwnsOne("LexCore.Entities.ProjectWritingSystems", "WritingSystems", b1 => + { + b1.Property("FlexProjectMetadataProjectId") + .HasColumnType("uuid"); + + b1.HasKey("FlexProjectMetadataProjectId"); + + b1.ToTable("FlexProjectMetadata"); + + b1.ToJson("WritingSystems"); + + b1.WithOwner() + .HasForeignKey("FlexProjectMetadataProjectId"); + + b1.OwnsMany("LexCore.Entities.FLExWsId", "AnalysisWss", b2 => + { + b2.Property("ProjectWritingSystemsFlexProjectMetadataProjectId") + .HasColumnType("uuid"); + + b2.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + b2.Property("IsActive") + .HasColumnType("boolean"); + + b2.Property("IsDefault") + .HasColumnType("boolean"); + + b2.Property("Tag") + .IsRequired() + .HasColumnType("text"); + + b2.HasKey("ProjectWritingSystemsFlexProjectMetadataProjectId", "Id"); + + b2.ToTable("FlexProjectMetadata"); + + b2.WithOwner() + .HasForeignKey("ProjectWritingSystemsFlexProjectMetadataProjectId"); + }); + + b1.OwnsMany("LexCore.Entities.FLExWsId", "VernacularWss", b2 => + { + b2.Property("ProjectWritingSystemsFlexProjectMetadataProjectId") + .HasColumnType("uuid"); + + b2.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + b2.Property("IsActive") + .HasColumnType("boolean"); + + b2.Property("IsDefault") + .HasColumnType("boolean"); + + b2.Property("Tag") + .IsRequired() + .HasColumnType("text"); + + b2.HasKey("ProjectWritingSystemsFlexProjectMetadataProjectId", "Id"); + + b2.ToTable("FlexProjectMetadata"); + + b2.WithOwner() + .HasForeignKey("ProjectWritingSystemsFlexProjectMetadataProjectId"); + }); + + b1.Navigation("AnalysisWss"); + + b1.Navigation("VernacularWss"); + }); + + b.Navigation("WritingSystems"); + }); + + 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.OrgProjects", b => + { + b.HasOne("LexCore.Entities.Organization", "Org") + .WithMany() + .HasForeignKey("OrgId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LexCore.Entities.Project", "Project") + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Org"); + + b.Navigation("Project"); + }); + + 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("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("SIL.Harmony.Core.ServerCommit", b => + { + b.HasOne("LexCore.Entities.FlexProjectMetadata", null) + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsMany("SIL.Harmony.Core.ChangeEntity", "ChangeEntities", b1 => + { + b1.Property("ServerCommitId") + .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("ServerCommitId", "Id"); + + b1.ToTable("CrdtCommits"); + + b1.ToJson("ChangeEntities"); + + b1.WithOwner() + .HasForeignKey("ServerCommitId"); + }); + + b.Navigation("ChangeEntities"); + }); + + 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/20241023132940_Make org names case insensitive.cs b/backend/LexData/Migrations/20241023132940_Make org names case insensitive.cs new file mode 100644 index 000000000..0463d952b --- /dev/null +++ b/backend/LexData/Migrations/20241023132940_Make org names case insensitive.cs @@ -0,0 +1,36 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace LexData.Migrations +{ + /// + public partial class Makeorgnamescaseinsensitive : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "Name", + table: "Orgs", + type: "text", + nullable: false, + collation: "case_insensitive", + oldClrType: typeof(string), + oldType: "text"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "Name", + table: "Orgs", + type: "text", + nullable: false, + oldClrType: typeof(string), + oldType: "text", + oldCollation: "case_insensitive"); + } + } +} diff --git a/backend/LexData/Migrations/LexBoxDbContextModelSnapshot.cs b/backend/LexData/Migrations/LexBoxDbContextModelSnapshot.cs index daef50c7a..64e812c42 100644 --- a/backend/LexData/Migrations/LexBoxDbContextModelSnapshot.cs +++ b/backend/LexData/Migrations/LexBoxDbContextModelSnapshot.cs @@ -619,7 +619,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Name") .IsRequired() - .HasColumnType("text"); + .HasColumnType("text") + .UseCollation("case_insensitive"); b.Property("UpdatedDate") .ValueGeneratedOnAdd() diff --git a/backend/Testing/Services/TestingEnvironmentVariables.cs b/backend/Testing/Services/TestingEnvironmentVariables.cs index 8b9d29e7a..6618c98a4 100644 --- a/backend/Testing/Services/TestingEnvironmentVariables.cs +++ b/backend/Testing/Services/TestingEnvironmentVariables.cs @@ -2,7 +2,7 @@ namespace Testing.Services; public static class TestingEnvironmentVariables { - public static string ServerHostname = Environment.GetEnvironmentVariable("TEST_SERVER_HOSTNAME") ?? "localhost"; + public static string ServerHostname = Environment.GetEnvironmentVariable("TEST_SERVER_HOSTNAME") ?? "localhost:5158"; public static readonly bool IsDev = ServerHostname.StartsWith("localhost"); //scheme like https:// or http:// public static string HttpScheme = (Environment.GetEnvironmentVariable("TEST_HTTP_SCHEME") ?? (IsDev ? "http" : "https")) + "://"; diff --git a/deployment/base/fw-headless-deployment.yaml b/deployment/base/fw-headless-deployment.yaml new file mode 100644 index 000000000..f61485e53 --- /dev/null +++ b/deployment/base/fw-headless-deployment.yaml @@ -0,0 +1,139 @@ +# https://kubernetes.io/docs/concepts/services-networking/service/#defining-a-service +apiVersion: v1 +kind: Service +metadata: + name: fw-headless + namespace: languagedepot + labels: + app: fw-headless +spec: + type: ClusterIP + clusterIP: None + selector: + app: fw-headless + ports: + - name: http + protocol: TCP + port: 80 + +--- + +# https://kubernetes.io/docs/concepts/workloads/controllers/deployment/#writing-a-deployment-spec +apiVersion: apps/v1 +kind: Deployment +metadata: + name: fw-headless + namespace: languagedepot + labels: + app: fw-headless +spec: + selector: + matchLabels: + app: fw-headless + strategy: + rollingUpdate: + maxSurge: 2 + maxUnavailable: 0 + type: RollingUpdate + template: + # https://kubernetes.io/docs/concepts/workloads/pods/#pod-templates + metadata: + labels: + app: fw-headless + spec: + securityContext: + runAsGroup: 33 + runAsUser: 33 + runAsNonRoot: true + containers: + - name: fw-headless + image: ghcr.io/sillsdev/lexbox-fw-headless:develop + imagePullPolicy: IfNotPresent + # https://kubernetes.io/docs/concepts/configuration/manage-resources-containers + resources: + requests: + memory: 1500Mi + limits: + memory: 2400Mi + startupProbe: + httpGet: + port: 80 + path: /api/healthz + failureThreshold: 30 + periodSeconds: 10 + ports: + - containerPort: 80 + + volumeMounts: + - name: fw-headless + mountPath: /var/lib/fw-headless + + env: + - name: DOTNET_URLS + value: http://0.0.0.0:80 + - name: ASPNETCORE_ENVIRONMENT + valueFrom: + configMapKeyRef: + name: app-config + key: environment-name + - name: K8S_NODE_NAME + valueFrom: + fieldRef: + fieldPath: spec.nodeName + - name: K8S_POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name + - name: POSTGRES_DB + valueFrom: + secretKeyRef: + key: POSTGRES_DB + name: db + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + key: POSTGRES_PASSWORD + name: db + - name: DbConfig__LexBoxConnectionString + value: Host=db;Port=5432;Username=postgres;Password=$(POSTGRES_PASSWORD);Database=$(POSTGRES_DB) + - name: FwHeadlessConfig__ProjectStorageRoot + value: /var/lib/fw-headless/projects + - name: FwHeadlessConfig__LexboxUrl + value: http://lexbox:5158/ + # - name: FwHeadlessConfig__HgUrl + # value: http://lexbox:5158/hg/ + - name: FwHeadlessConfig__LexboxUsername + valueFrom: + secretKeyRef: + key: CRDT_MERGE_SEND_RECEIVE_USERNAME + name: fw-headless + - name: FwHeadlessConfig__LexboxPassword + valueFrom: + secretKeyRef: + key: CRDT_MERGE_SEND_RECEIVE_PASSWORD + name: fw-headless + - name: FwHeadlessConfig__FdoDataModelVersion + value: "7000072" + + initContainers: + - name: populate-crdt-project-storage + securityContext: + # Make sure we're authorized to set ownership + runAsUser: 0 + runAsGroup: 0 + runAsNonRoot: false + image: busybox:1.36.1 + command: + - 'sh' + - '-c' + - | + mkdir -p /fw-headless/projects + chown www-data:www-data /fw-headless/projects + volumeMounts: + - name: fw-headless + mountPath: /fw-headless + + volumes: + - name: fw-headless + persistentVolumeClaim: + claimName: fw-headless # established in pvc.yaml diff --git a/deployment/base/kustomization.yaml b/deployment/base/kustomization.yaml index fe9002a1a..0dc486889 100644 --- a/deployment/base/kustomization.yaml +++ b/deployment/base/kustomization.yaml @@ -9,6 +9,7 @@ resources: - pvc.yaml - db-deployment.yaml - hg-deployment.yaml +- fw-headless-deployment.yaml - lexbox-deployment.yaml - ui-deployment.yaml - proxy-deployment.yaml diff --git a/deployment/base/pvc.yaml b/deployment/base/pvc.yaml index 8ffbdb558..6ea3f7ce7 100644 --- a/deployment/base/pvc.yaml +++ b/deployment/base/pvc.yaml @@ -33,3 +33,21 @@ spec: requests: storage: 10Gi storageClassName: weekly-snapshots-retain-4 # provided by LTOps + +--- + +# https://kubernetes.io/docs/concepts/storage/persistent-volumes/#persistentvolumeclaims +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: fw-headless + namespace: languagedepot + labels: + app.kubernetes.io/part-of: languagedepot +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 10Gi + storageClassName: weekly-snapshots-retain-4 # provided by LTOps diff --git a/deployment/base/secrets.yaml b/deployment/base/secrets.yaml index 94236d32a..7046f12a3 100644 --- a/deployment/base/secrets.yaml +++ b/deployment/base/secrets.yaml @@ -84,3 +84,13 @@ metadata: stringData: KOPIA_PASSWORD: '' kopia.config: '' +--- + +apiVersion: v1 +kind: Secret +metadata: + name: fw-headless + namespace: languagedepot +stringData: + CRDT_MERGE_SEND_RECEIVE_USERNAME: '' + CRDT_MERGE_SEND_RECEIVE_PASSWORD: '' diff --git a/deployment/develop/fw-headless-deployment.patch.yaml b/deployment/develop/fw-headless-deployment.patch.yaml new file mode 100644 index 000000000..9df814838 --- /dev/null +++ b/deployment/develop/fw-headless-deployment.patch.yaml @@ -0,0 +1,24 @@ +#file: noinspection KubernetesMissingKeys,KubernetesUnknownValues +apiVersion: apps/v1 +kind: Deployment +metadata: + name: fw-headless + namespace: languagedepot + labels: + app: fw-headless +spec: + template: + spec: + containers: + - name: fw-headless + imagePullPolicy: IfNotPresent + env: + - name: FwHeadlessConfig__LexboxUsername + value: "admin" + valueFrom: # don't use secret as defined in base + - name: FwHeadlessConfig__LexboxPassword + valueFrom: + secretKeyRef: + key: SEED_USER_PASSWORD + name: db + optional: true diff --git a/deployment/develop/kustomization.yaml b/deployment/develop/kustomization.yaml index 7ba6e3340..c86d42508 100644 --- a/deployment/develop/kustomization.yaml +++ b/deployment/develop/kustomization.yaml @@ -7,6 +7,7 @@ resources: components: - ../init-repos patches: + - path: fw-headless-deployment.patch.yaml - path: lexbox-deployment.patch.yaml - path: app-config.yaml - path: ingress-config.yaml diff --git a/deployment/local-dev/fw-headless-deployment.patch.yaml b/deployment/local-dev/fw-headless-deployment.patch.yaml new file mode 100644 index 000000000..9df814838 --- /dev/null +++ b/deployment/local-dev/fw-headless-deployment.patch.yaml @@ -0,0 +1,24 @@ +#file: noinspection KubernetesMissingKeys,KubernetesUnknownValues +apiVersion: apps/v1 +kind: Deployment +metadata: + name: fw-headless + namespace: languagedepot + labels: + app: fw-headless +spec: + template: + spec: + containers: + - name: fw-headless + imagePullPolicy: IfNotPresent + env: + - name: FwHeadlessConfig__LexboxUsername + value: "admin" + valueFrom: # don't use secret as defined in base + - name: FwHeadlessConfig__LexboxPassword + valueFrom: + secretKeyRef: + key: SEED_USER_PASSWORD + name: db + optional: true diff --git a/deployment/local-dev/kustomization.yaml b/deployment/local-dev/kustomization.yaml index 02e526433..1fb7ad2fa 100644 --- a/deployment/local-dev/kustomization.yaml +++ b/deployment/local-dev/kustomization.yaml @@ -39,6 +39,7 @@ patches: path: delete-oauth-certs.yaml - path: app-config.yaml + - path: fw-headless-deployment.patch.yaml - path: lexbox-deployment.patch.yaml - path: ui-deployment.patch.yaml - path: hg-repos-pvc.patch.yaml diff --git a/deployment/production/fw-headless-volume.yaml b/deployment/production/fw-headless-volume.yaml new file mode 100644 index 000000000..6ff2a11c1 --- /dev/null +++ b/deployment/production/fw-headless-volume.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: hg-repos + namespace: languagedepot +spec: + resources: + requests: + storage: 150Gi diff --git a/deployment/production/kustomization.yaml b/deployment/production/kustomization.yaml index f35c39514..e79de016c 100644 --- a/deployment/production/kustomization.yaml +++ b/deployment/production/kustomization.yaml @@ -9,6 +9,7 @@ resources: patches: - path: lexbox-deployment.patch.yaml - path: app-config.yaml + - path: fw-headless-volume.yaml - path: hg-repos-volume.yaml - path: hg-deployment.yaml - path: ingress-config-prod.yaml diff --git a/deployment/staging/fw-headless-deployment.patch.yaml b/deployment/staging/fw-headless-deployment.patch.yaml new file mode 100644 index 000000000..9df814838 --- /dev/null +++ b/deployment/staging/fw-headless-deployment.patch.yaml @@ -0,0 +1,24 @@ +#file: noinspection KubernetesMissingKeys,KubernetesUnknownValues +apiVersion: apps/v1 +kind: Deployment +metadata: + name: fw-headless + namespace: languagedepot + labels: + app: fw-headless +spec: + template: + spec: + containers: + - name: fw-headless + imagePullPolicy: IfNotPresent + env: + - name: FwHeadlessConfig__LexboxUsername + value: "admin" + valueFrom: # don't use secret as defined in base + - name: FwHeadlessConfig__LexboxPassword + valueFrom: + secretKeyRef: + key: SEED_USER_PASSWORD + name: db + optional: true diff --git a/deployment/staging/fw-headless-volume.yaml b/deployment/staging/fw-headless-volume.yaml new file mode 100644 index 000000000..97bb99558 --- /dev/null +++ b/deployment/staging/fw-headless-volume.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: fw-headless + namespace: languagedepot +spec: + resources: + requests: + storage: 30Gi diff --git a/deployment/staging/kustomization.yaml b/deployment/staging/kustomization.yaml index 66768dc21..c56c8d483 100644 --- a/deployment/staging/kustomization.yaml +++ b/deployment/staging/kustomization.yaml @@ -7,6 +7,8 @@ resources: components: - ../init-repos patches: + - path: fw-headless-volume.yaml + - path: fw-headless-deployment.patch.yaml - path: lexbox-deployment.patch.yaml - path: app-config.yaml - path: hg-repos-volume.yaml diff --git a/frontend/package.json b/frontend/package.json index 7f5831bb1..08848abe8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -25,7 +25,7 @@ "lint": "svelte-kit sync && eslint .", "lint:report": "svelte-kit sync && eslint . --output-file eslint_report.json --format json", "format": "eslint . --fix", - "clean": "rimraf node_modules .svelte-kit" + "clean": "pnpm dlx rimraf node_modules .svelte-kit" }, "devDependencies": { "@dotenvx/dotenvx": "^0.37.1", diff --git a/frontend/schema.graphql b/frontend/schema.graphql index a82612c7a..7624aaa32 100644 --- a/frontend/schema.graphql +++ b/frontend/schema.graphql @@ -200,6 +200,11 @@ type LastMemberCantLeaveError implements Error { message: String! } +type LeaveOrgPayload { + organization: Organization + errors: [LeaveOrgError!] +} + type LeaveProjectPayload { project: Project errors: [LeaveProjectError!] @@ -232,37 +237,38 @@ type MeDto { } type Mutation { - createOrganization(input: CreateOrganizationInput!): CreateOrganizationPayload! - deleteOrg(input: DeleteOrgInput!): DeleteOrgPayload! @authorize(policy: "AdminRequiredPolicy") - addProjectToOrg(input: AddProjectToOrgInput!): AddProjectToOrgPayload! - addProjectsToOrg(input: AddProjectsToOrgInput!): AddProjectsToOrgPayload! - removeProjectFromOrg(input: RemoveProjectFromOrgInput!): RemoveProjectFromOrgPayload! - setOrgMemberRole(input: SetOrgMemberRoleInput!): SetOrgMemberRolePayload! - changeOrgMemberRole(input: ChangeOrgMemberRoleInput!): ChangeOrgMemberRolePayload! - bulkAddOrgMembers(input: BulkAddOrgMembersInput!): BulkAddOrgMembersPayload! - changeOrgName(input: ChangeOrgNameInput!): ChangeOrgNamePayload! - createProject(input: CreateProjectInput!): CreateProjectPayload! @authorize(policy: "VerifiedEmailRequiredPolicy") - addProjectMember(input: AddProjectMemberInput!): AddProjectMemberPayload! - bulkAddProjectMembers(input: BulkAddProjectMembersInput!): BulkAddProjectMembersPayload! @authorize(policy: "AdminRequiredPolicy") - changeProjectMemberRole(input: ChangeProjectMemberRoleInput!): ChangeProjectMemberRolePayload! - askToJoinProject(input: AskToJoinProjectInput!): AskToJoinProjectPayload! - changeProjectName(input: ChangeProjectNameInput!): ChangeProjectNamePayload! - changeProjectDescription(input: ChangeProjectDescriptionInput!): ChangeProjectDescriptionPayload! - setProjectConfidentiality(input: SetProjectConfidentialityInput!): SetProjectConfidentialityPayload! - setRetentionPolicy(input: SetRetentionPolicyInput!): SetRetentionPolicyPayload! - updateProjectLexEntryCount(input: UpdateProjectLexEntryCountInput!): UpdateProjectLexEntryCountPayload! - updateProjectLanguageList(input: UpdateProjectLanguageListInput!): UpdateProjectLanguageListPayload! - updateLangProjectId(input: UpdateLangProjectIdInput!): UpdateLangProjectIdPayload! - updateFLExModelVersion(input: UpdateFLExModelVersionInput!): UpdateFLExModelVersionPayload! - leaveProject(input: LeaveProjectInput!): LeaveProjectPayload! - removeProjectMember(input: RemoveProjectMemberInput!): RemoveProjectMemberPayload! - deleteDraftProject(input: DeleteDraftProjectInput!): DeleteDraftProjectPayload! @authorize(policy: "AdminRequiredPolicy") - 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") + createOrganization(input: CreateOrganizationInput!): CreateOrganizationPayload! @cost(weight: "10") + deleteOrg(input: DeleteOrgInput!): DeleteOrgPayload! @authorize(policy: "AdminRequiredPolicy") @cost(weight: "10") + addProjectToOrg(input: AddProjectToOrgInput!): AddProjectToOrgPayload! @cost(weight: "10") + addProjectsToOrg(input: AddProjectsToOrgInput!): AddProjectsToOrgPayload! @cost(weight: "10") + removeProjectFromOrg(input: RemoveProjectFromOrgInput!): RemoveProjectFromOrgPayload! @cost(weight: "10") + setOrgMemberRole(input: SetOrgMemberRoleInput!): SetOrgMemberRolePayload! @cost(weight: "10") + changeOrgMemberRole(input: ChangeOrgMemberRoleInput!): ChangeOrgMemberRolePayload! @cost(weight: "10") + leaveOrg(input: LeaveOrgInput!): LeaveOrgPayload! @cost(weight: "10") + bulkAddOrgMembers(input: BulkAddOrgMembersInput!): BulkAddOrgMembersPayload! @cost(weight: "10") + changeOrgName(input: ChangeOrgNameInput!): ChangeOrgNamePayload! @cost(weight: "10") + createProject(input: CreateProjectInput!): CreateProjectPayload! @authorize(policy: "VerifiedEmailRequiredPolicy") @cost(weight: "10") + addProjectMember(input: AddProjectMemberInput!): AddProjectMemberPayload! @cost(weight: "10") + bulkAddProjectMembers(input: BulkAddProjectMembersInput!): BulkAddProjectMembersPayload! @authorize(policy: "AdminRequiredPolicy") @cost(weight: "10") + changeProjectMemberRole(input: ChangeProjectMemberRoleInput!): ChangeProjectMemberRolePayload! @cost(weight: "10") + askToJoinProject(input: AskToJoinProjectInput!): AskToJoinProjectPayload! @cost(weight: "10") + changeProjectName(input: ChangeProjectNameInput!): ChangeProjectNamePayload! @cost(weight: "10") + changeProjectDescription(input: ChangeProjectDescriptionInput!): ChangeProjectDescriptionPayload! @cost(weight: "10") + setProjectConfidentiality(input: SetProjectConfidentialityInput!): SetProjectConfidentialityPayload! @cost(weight: "10") + setRetentionPolicy(input: SetRetentionPolicyInput!): SetRetentionPolicyPayload! @cost(weight: "10") + updateProjectLexEntryCount(input: UpdateProjectLexEntryCountInput!): UpdateProjectLexEntryCountPayload! @cost(weight: "10") + updateProjectLanguageList(input: UpdateProjectLanguageListInput!): UpdateProjectLanguageListPayload! @cost(weight: "10") + updateLangProjectId(input: UpdateLangProjectIdInput!): UpdateLangProjectIdPayload! @cost(weight: "10") + updateFLExModelVersion(input: UpdateFLExModelVersionInput!): UpdateFLExModelVersionPayload! @cost(weight: "10") + leaveProject(input: LeaveProjectInput!): LeaveProjectPayload! @cost(weight: "10") + removeProjectMember(input: RemoveProjectMemberInput!): RemoveProjectMemberPayload! @cost(weight: "10") + deleteDraftProject(input: DeleteDraftProjectInput!): DeleteDraftProjectPayload! @authorize(policy: "AdminRequiredPolicy") @cost(weight: "10") + softDeleteProject(input: SoftDeleteProjectInput!): SoftDeleteProjectPayload! @cost(weight: "10") + changeUserAccountBySelf(input: ChangeUserAccountBySelfInput!): ChangeUserAccountBySelfPayload! @cost(weight: "10") + changeUserAccountByAdmin(input: ChangeUserAccountByAdminInput!): ChangeUserAccountByAdminPayload! @authorize(policy: "AdminRequiredPolicy") @cost(weight: "10") + createGuestUserByAdmin(input: CreateGuestUserByAdminInput!): CreateGuestUserByAdminPayload! @authorize(policy: "AdminRequiredPolicy") @cost(weight: "10") + deleteUserByAdminOrSelf(input: DeleteUserByAdminOrSelfInput!): DeleteUserByAdminOrSelfPayload! @cost(weight: "10") + setUserLocked(input: SetUserLockedInput!): SetUserLockedPayload! @authorize(policy: "AdminRequiredPolicy") @cost(weight: "10") } type NotFoundError implements Error { @@ -366,9 +372,9 @@ type Project { createdDate: DateTime! id: UUID! users: [ProjectUsers!]! - changesets: [Changeset!]! + changesets: [Changeset!]! @cost(weight: "10") hasAbandonedTransactions: Boolean! - isLanguageForgeProject: Boolean! + isLanguageForgeProject: Boolean! @cost(weight: "10") parentId: UUID name: String! description: String @@ -419,22 +425,22 @@ type ProjectWritingSystems { } type Query { - myProjects(orderBy: [ProjectSortInput!]): [Project!]! - projects(withDeleted: Boolean! = false where: ProjectFilterInput orderBy: [ProjectSortInput!]): [Project!]! @authorize(policy: "AdminRequiredPolicy") - myDraftProjects(orderBy: [DraftProjectSortInput!]): [DraftProject!]! - draftProjects(where: DraftProjectFilterInput orderBy: [DraftProjectSortInput!]): [DraftProject!]! @authorize(policy: "AdminRequiredPolicy") - projectsByLangCodeAndOrg(input: ProjectsByLangCodeAndOrgInput! orderBy: [ProjectSortInput!]): [Project!]! - projectsInMyOrg(input: ProjectsInMyOrgInput! where: ProjectFilterInput orderBy: [ProjectSortInput!]): [Project!]! - projectById(projectId: UUID!): Project - projectByCode(code: String!): Project - draftProjectByCode(code: String!): DraftProject @authorize(policy: "AdminRequiredPolicy") - orgs(where: OrganizationFilterInput orderBy: [OrganizationSortInput!]): [Organization!]! - myOrgs(where: OrganizationFilterInput orderBy: [OrganizationSortInput!]): [Organization!]! - usersInMyOrg(skip: Int take: Int where: UserFilterInput orderBy: [UserSortInput!]): UsersInMyOrgCollectionSegment - orgById(orgId: UUID!): OrgById - users(skip: Int take: Int where: UserFilterInput orderBy: [UserSortInput!]): UsersCollectionSegment @authorize(policy: "AdminRequiredPolicy") - me: MeDto - orgMemberById(orgId: UUID! userId: UUID!): OrgMemberDto + myProjects(orderBy: [ProjectSortInput!] @cost(weight: "10")): [Project!]! @cost(weight: "10") + projects(withDeleted: Boolean! = false where: ProjectFilterInput @cost(weight: "10") orderBy: [ProjectSortInput!] @cost(weight: "10")): [Project!]! @authorize(policy: "AdminRequiredPolicy") @cost(weight: "10") + myDraftProjects(orderBy: [DraftProjectSortInput!] @cost(weight: "10")): [DraftProject!]! @cost(weight: "10") + draftProjects(where: DraftProjectFilterInput @cost(weight: "10") orderBy: [DraftProjectSortInput!] @cost(weight: "10")): [DraftProject!]! @authorize(policy: "AdminRequiredPolicy") @cost(weight: "10") + projectsByLangCodeAndOrg(input: ProjectsByLangCodeAndOrgInput! orderBy: [ProjectSortInput!] @cost(weight: "10")): [Project!]! @cost(weight: "10") + projectsInMyOrg(input: ProjectsInMyOrgInput! where: ProjectFilterInput @cost(weight: "10") orderBy: [ProjectSortInput!] @cost(weight: "10")): [Project!]! @cost(weight: "10") + projectById(projectId: UUID!): Project @cost(weight: "10") + projectByCode(code: String!): Project @cost(weight: "10") + draftProjectByCode(code: String!): DraftProject @authorize(policy: "AdminRequiredPolicy") @cost(weight: "10") + orgs(where: OrganizationFilterInput @cost(weight: "10") orderBy: [OrganizationSortInput!] @cost(weight: "10")): [Organization!]! @cost(weight: "10") + myOrgs(where: OrganizationFilterInput @cost(weight: "10") orderBy: [OrganizationSortInput!] @cost(weight: "10")): [Organization!]! @cost(weight: "10") + usersInMyOrg(skip: Int take: Int where: UserFilterInput @cost(weight: "10") orderBy: [UserSortInput!] @cost(weight: "10")): UsersInMyOrgCollectionSegment @listSize(assumedSize: 1000, slicingArguments: [ "take" ], sizedFields: [ "items" ]) @cost(weight: "10") + orgById(orgId: UUID!): OrgById @cost(weight: "10") + users(skip: Int take: Int where: UserFilterInput @cost(weight: "10") orderBy: [UserSortInput!] @cost(weight: "10")): UsersCollectionSegment @authorize(policy: "AdminRequiredPolicy") @listSize(assumedSize: 1000, slicingArguments: [ "take" ], sizedFields: [ "items" ]) @cost(weight: "10") + me: MeDto @cost(weight: "10") + orgMemberById(orgId: UUID! userId: UUID!): OrgMemberDto @cost(weight: "10") meAuth: LexAuthUser! testingThrowsError: LexAuthUser! isAdmin: IsAdminResponse! @authorize(policy: "AdminRequiredPolicy") @@ -539,7 +545,7 @@ type UsersCollectionSegment { pageInfo: CollectionSegmentInfo! "A flattened list of the items." items: [User!] - totalCount: Int! + totalCount: Int! @cost(weight: "10") } "A segment of a collection." @@ -548,7 +554,7 @@ type UsersInMyOrgCollectionSegment { pageInfo: CollectionSegmentInfo! "A flattened list of the items." items: [User!] - totalCount: Int! + totalCount: Int! @cost(weight: "10") } union AddProjectMemberError = NotFoundError | DbError | ProjectMembersMustBeVerified | ProjectMembersMustBeVerifiedForRole | ProjectMemberInvitedByEmail | InvalidEmailError | AlreadyExistsError @@ -589,6 +595,8 @@ union DeleteOrgError = DbError union DeleteUserByAdminOrSelfError = NotFoundError | DbError +union LeaveOrgError = NotFoundError | LastMemberCantLeaveError + union LeaveProjectError = NotFoundError | LastMemberCantLeaveError union RemoveProjectFromOrgError = DbError | NotFoundError @@ -634,8 +642,8 @@ input AskToJoinProjectInput { } input BooleanOperationFilterInput { - eq: Boolean - neq: Boolean + eq: Boolean @cost(weight: "10") + neq: Boolean @cost(weight: "10") } input BulkAddOrgMembersInput { @@ -718,18 +726,18 @@ input CreateProjectInput { } input DateTimeOperationFilterInput { - eq: DateTime - neq: DateTime - in: [DateTime] - nin: [DateTime] - gt: DateTime - ngt: DateTime - gte: DateTime - ngte: DateTime - lt: DateTime - nlt: DateTime - lte: DateTime - nlte: DateTime + eq: DateTime @cost(weight: "10") + neq: DateTime @cost(weight: "10") + in: [DateTime] @cost(weight: "10") + nin: [DateTime] @cost(weight: "10") + gt: DateTime @cost(weight: "10") + ngt: DateTime @cost(weight: "10") + gte: DateTime @cost(weight: "10") + ngte: DateTime @cost(weight: "10") + lt: DateTime @cost(weight: "10") + nlt: DateTime @cost(weight: "10") + lte: DateTime @cost(weight: "10") + nlte: DateTime @cost(weight: "10") } input DeleteDraftProjectInput { @@ -762,18 +770,18 @@ input DraftProjectFilterInput { } input DraftProjectSortInput { - name: SortEnumType - description: SortEnumType - code: SortEnumType - type: SortEnumType - retentionPolicy: SortEnumType - projectManager: UserSortInput - projectManagerId: SortEnumType - orgId: SortEnumType - isConfidential: SortEnumType - id: SortEnumType - createdDate: SortEnumType - updatedDate: SortEnumType + name: SortEnumType @cost(weight: "10") + description: SortEnumType @cost(weight: "10") + code: SortEnumType @cost(weight: "10") + type: SortEnumType @cost(weight: "10") + retentionPolicy: SortEnumType @cost(weight: "10") + projectManager: UserSortInput @cost(weight: "10") + projectManagerId: SortEnumType @cost(weight: "10") + orgId: SortEnumType @cost(weight: "10") + isConfidential: SortEnumType @cost(weight: "10") + id: SortEnumType @cost(weight: "10") + createdDate: SortEnumType @cost(weight: "10") + updatedDate: SortEnumType @cost(weight: "10") } input FLExWsIdFilterInput { @@ -795,25 +803,29 @@ input FlexProjectMetadataFilterInput { } input FlexProjectMetadataSortInput { - projectId: SortEnumType - lexEntryCount: SortEnumType - langProjectId: SortEnumType - flexModelVersion: SortEnumType + projectId: SortEnumType @cost(weight: "10") + lexEntryCount: SortEnumType @cost(weight: "10") + langProjectId: SortEnumType @cost(weight: "10") + flexModelVersion: SortEnumType @cost(weight: "10") } input IntOperationFilterInput { - eq: Int - neq: Int - in: [Int] - nin: [Int] - gt: Int - ngt: Int - gte: Int - ngte: Int - lt: Int - nlt: Int - lte: Int - nlte: Int + eq: Int @cost(weight: "10") + neq: Int @cost(weight: "10") + in: [Int] @cost(weight: "10") + nin: [Int] @cost(weight: "10") + gt: Int @cost(weight: "10") + ngt: Int @cost(weight: "10") + gte: Int @cost(weight: "10") + ngte: Int @cost(weight: "10") + lt: Int @cost(weight: "10") + nlt: Int @cost(weight: "10") + lte: Int @cost(weight: "10") + nlte: Int @cost(weight: "10") +} + +input LeaveOrgInput { + orgId: UUID! } input LeaveProjectInput { @@ -821,45 +833,45 @@ input LeaveProjectInput { } input ListFilterInputTypeOfFLExWsIdFilterInput { - all: FLExWsIdFilterInput - none: FLExWsIdFilterInput - some: FLExWsIdFilterInput - any: Boolean + all: FLExWsIdFilterInput @cost(weight: "10") + none: FLExWsIdFilterInput @cost(weight: "10") + some: FLExWsIdFilterInput @cost(weight: "10") + any: Boolean @cost(weight: "10") } input ListFilterInputTypeOfOrgMemberFilterInput { - all: OrgMemberFilterInput - none: OrgMemberFilterInput - some: OrgMemberFilterInput - any: Boolean + all: OrgMemberFilterInput @cost(weight: "10") + none: OrgMemberFilterInput @cost(weight: "10") + some: OrgMemberFilterInput @cost(weight: "10") + any: Boolean @cost(weight: "10") } input ListFilterInputTypeOfOrganizationFilterInput { - all: OrganizationFilterInput - none: OrganizationFilterInput - some: OrganizationFilterInput - any: Boolean + all: OrganizationFilterInput @cost(weight: "10") + none: OrganizationFilterInput @cost(weight: "10") + some: OrganizationFilterInput @cost(weight: "10") + any: Boolean @cost(weight: "10") } input ListFilterInputTypeOfProjectFilterInput { - all: ProjectFilterInput - none: ProjectFilterInput - some: ProjectFilterInput - any: Boolean + all: ProjectFilterInput @cost(weight: "10") + none: ProjectFilterInput @cost(weight: "10") + some: ProjectFilterInput @cost(weight: "10") + any: Boolean @cost(weight: "10") } input ListFilterInputTypeOfProjectUsersFilterInput { - all: ProjectUsersFilterInput - none: ProjectUsersFilterInput - some: ProjectUsersFilterInput - any: Boolean + all: ProjectUsersFilterInput @cost(weight: "10") + none: ProjectUsersFilterInput @cost(weight: "10") + some: ProjectUsersFilterInput @cost(weight: "10") + any: Boolean @cost(weight: "10") } input ListFilterInputTypeOfUserFilterInput { - all: UserFilterInput - none: UserFilterInput - some: UserFilterInput - any: Boolean + all: UserFilterInput @cost(weight: "10") + none: UserFilterInput @cost(weight: "10") + some: UserFilterInput @cost(weight: "10") + any: Boolean @cost(weight: "10") } input OrgMemberFilterInput { @@ -876,10 +888,10 @@ input OrgMemberFilterInput { } input OrgRoleOperationFilterInput { - eq: OrgRole - neq: OrgRole - in: [OrgRole!] - nin: [OrgRole!] + eq: OrgRole @cost(weight: "10") + neq: OrgRole @cost(weight: "10") + in: [OrgRole!] @cost(weight: "10") + nin: [OrgRole!] @cost(weight: "10") } input OrganizationFilterInput { @@ -896,12 +908,12 @@ input OrganizationFilterInput { } input OrganizationSortInput { - name: SortEnumType - memberCount: SortEnumType - projectCount: SortEnumType - id: SortEnumType - createdDate: SortEnumType - updatedDate: SortEnumType + name: SortEnumType @cost(weight: "10") + memberCount: SortEnumType @cost(weight: "10") + projectCount: SortEnumType @cost(weight: "10") + id: SortEnumType @cost(weight: "10") + createdDate: SortEnumType @cost(weight: "10") + updatedDate: SortEnumType @cost(weight: "10") } input ProjectFilterInput { @@ -929,44 +941,44 @@ input ProjectFilterInput { } input ProjectMigrationStatusOperationFilterInput { - eq: ProjectMigrationStatus - neq: ProjectMigrationStatus - in: [ProjectMigrationStatus!] - nin: [ProjectMigrationStatus!] + eq: ProjectMigrationStatus @cost(weight: "10") + neq: ProjectMigrationStatus @cost(weight: "10") + in: [ProjectMigrationStatus!] @cost(weight: "10") + nin: [ProjectMigrationStatus!] @cost(weight: "10") } input ProjectRoleOperationFilterInput { - eq: ProjectRole - neq: ProjectRole - in: [ProjectRole!] - nin: [ProjectRole!] + eq: ProjectRole @cost(weight: "10") + neq: ProjectRole @cost(weight: "10") + in: [ProjectRole!] @cost(weight: "10") + nin: [ProjectRole!] @cost(weight: "10") } input ProjectSortInput { - parentId: SortEnumType - code: SortEnumType - name: SortEnumType - description: SortEnumType - retentionPolicy: SortEnumType - type: SortEnumType - isConfidential: SortEnumType - flexProjectMetadata: FlexProjectMetadataSortInput - lastCommit: SortEnumType - deletedDate: SortEnumType - resetStatus: SortEnumType - projectOrigin: SortEnumType - migratedDate: SortEnumType - userCount: SortEnumType - id: SortEnumType - createdDate: SortEnumType - updatedDate: SortEnumType + parentId: SortEnumType @cost(weight: "10") + code: SortEnumType @cost(weight: "10") + name: SortEnumType @cost(weight: "10") + description: SortEnumType @cost(weight: "10") + retentionPolicy: SortEnumType @cost(weight: "10") + type: SortEnumType @cost(weight: "10") + isConfidential: SortEnumType @cost(weight: "10") + flexProjectMetadata: FlexProjectMetadataSortInput @cost(weight: "10") + lastCommit: SortEnumType @cost(weight: "10") + deletedDate: SortEnumType @cost(weight: "10") + resetStatus: SortEnumType @cost(weight: "10") + projectOrigin: SortEnumType @cost(weight: "10") + migratedDate: SortEnumType @cost(weight: "10") + userCount: SortEnumType @cost(weight: "10") + id: SortEnumType @cost(weight: "10") + createdDate: SortEnumType @cost(weight: "10") + updatedDate: SortEnumType @cost(weight: "10") } input ProjectTypeOperationFilterInput { - eq: ProjectType - neq: ProjectType - in: [ProjectType!] - nin: [ProjectType!] + eq: ProjectType @cost(weight: "10") + neq: ProjectType @cost(weight: "10") + in: [ProjectType!] @cost(weight: "10") + nin: [ProjectType!] @cost(weight: "10") } input ProjectUsersFilterInput { @@ -1009,17 +1021,17 @@ input RemoveProjectMemberInput { } input ResetStatusOperationFilterInput { - eq: ResetStatus - neq: ResetStatus - in: [ResetStatus!] - nin: [ResetStatus!] + eq: ResetStatus @cost(weight: "10") + neq: ResetStatus @cost(weight: "10") + in: [ResetStatus!] @cost(weight: "10") + nin: [ResetStatus!] @cost(weight: "10") } input RetentionPolicyOperationFilterInput { - eq: RetentionPolicy - neq: RetentionPolicy - in: [RetentionPolicy!] - nin: [RetentionPolicy!] + eq: RetentionPolicy @cost(weight: "10") + neq: RetentionPolicy @cost(weight: "10") + in: [RetentionPolicy!] @cost(weight: "10") + nin: [RetentionPolicy!] @cost(weight: "10") } input SetOrgMemberRoleInput { @@ -1051,18 +1063,18 @@ input SoftDeleteProjectInput { input StringOperationFilterInput { and: [StringOperationFilterInput!] or: [StringOperationFilterInput!] - eq: String - neq: String - contains: String - ncontains: String - in: [String] - nin: [String] - startsWith: String - nstartsWith: String - endsWith: String - nendsWith: String - icontains: String - ieq: String + eq: String @cost(weight: "10") + neq: String @cost(weight: "10") + contains: String @cost(weight: "20") + ncontains: String @cost(weight: "20") + in: [String] @cost(weight: "10") + nin: [String] @cost(weight: "10") + startsWith: String @cost(weight: "20") + nstartsWith: String @cost(weight: "20") + endsWith: String @cost(weight: "20") + nendsWith: String @cost(weight: "20") + icontains: String @cost(weight: "10") + ieq: String @cost(weight: "10") } input UpdateFLExModelVersionInput { @@ -1108,44 +1120,48 @@ input UserFilterInput { } input UserSortInput { - name: SortEnumType - email: SortEnumType - localizationCode: SortEnumType - isAdmin: SortEnumType - passwordHash: SortEnumType - salt: SortEnumType - passwordStrength: SortEnumType - lastActive: SortEnumType - emailVerified: SortEnumType - canCreateProjects: SortEnumType - createdById: SortEnumType - createdBy: UserSortInput - locked: SortEnumType - username: SortEnumType - googleId: SortEnumType - id: SortEnumType - createdDate: SortEnumType - updatedDate: SortEnumType + name: SortEnumType @cost(weight: "10") + email: SortEnumType @cost(weight: "10") + localizationCode: SortEnumType @cost(weight: "10") + isAdmin: SortEnumType @cost(weight: "10") + passwordHash: SortEnumType @cost(weight: "10") + salt: SortEnumType @cost(weight: "10") + passwordStrength: SortEnumType @cost(weight: "10") + lastActive: SortEnumType @cost(weight: "10") + emailVerified: SortEnumType @cost(weight: "10") + canCreateProjects: SortEnumType @cost(weight: "10") + createdById: SortEnumType @cost(weight: "10") + createdBy: UserSortInput @cost(weight: "10") + locked: SortEnumType @cost(weight: "10") + username: SortEnumType @cost(weight: "10") + googleId: SortEnumType @cost(weight: "10") + id: SortEnumType @cost(weight: "10") + createdDate: SortEnumType @cost(weight: "10") + updatedDate: SortEnumType @cost(weight: "10") } input UuidOperationFilterInput { - eq: UUID - neq: UUID - in: [UUID] - nin: [UUID] - gt: UUID - ngt: UUID - gte: UUID - ngte: UUID - lt: UUID - nlt: UUID - lte: UUID - nlte: UUID -} - + eq: UUID @cost(weight: "10") + neq: UUID @cost(weight: "10") + in: [UUID] @cost(weight: "10") + nin: [UUID] @cost(weight: "10") + gt: UUID @cost(weight: "10") + ngt: UUID @cost(weight: "10") + gte: UUID @cost(weight: "10") + ngte: UUID @cost(weight: "10") + lt: UUID @cost(weight: "10") + nlt: UUID @cost(weight: "10") + lte: UUID @cost(weight: "10") + nlte: UUID @cost(weight: "10") +} + +"Defines when a policy shall be executed." enum ApplyPolicy { + "Before the resolver was executed." BEFORE_RESOLVER + "After the resolver was executed." AFTER_RESOLVER + "The policy is applied in the validation step before the execution." VALIDATION } @@ -1220,8 +1236,18 @@ enum UserRole { USER } +"The authorize directive." directive @authorize("The name of the authorization policy that determines access to the annotated resource." policy: String "Roles that are allowed to access the annotated resource." roles: [String!] "Defines when when the authorize directive shall be applied.By default the authorize directives are applied during the validation phase." apply: ApplyPolicy! = BEFORE_RESOLVER) repeatable on OBJECT | FIELD_DEFINITION +"The purpose of the `cost` directive is to define a `weight` for GraphQL types, fields, and arguments. Static analysis can use these weights when calculating the overall cost of a query or response." +directive @cost("The `weight` argument defines what value to add to the overall cost for every appearance, or possible appearance, of a type, field, argument, etc." weight: String!) on SCALAR | OBJECT | FIELD_DEFINITION | ARGUMENT_DEFINITION | ENUM | INPUT_FIELD_DEFINITION + +"The purpose of the `@listSize` directive is to either inform the static analysis about the size of returned lists (if that information is statically available), or to point the analysis to where to find that information." +directive @listSize("The `assumedSize` argument can be used to statically define the maximum length of a list returned by a field." assumedSize: Int "The `slicingArguments` argument can be used to define which of the field's arguments with numeric type are slicing arguments, so that their value determines the size of the list returned by that field. It may specify a list of multiple slicing arguments." slicingArguments: [String!] "The `sizedFields` argument can be used to define that the value of the `assumedSize` argument or of a slicing argument does not affect the size of a list returned by a field itself, but that of a list returned by one of its sub-fields." sizedFields: [String!] "The `requireOneSlicingArgument` argument can be used to inform the static analysis that it should expect that exactly one of the defined slicing arguments is present in a query. If that is not the case (i.e., if none or multiple slicing arguments are present), the static analysis may throw an error." requireOneSlicingArgument: Boolean! = true) on FIELD_DEFINITION + +"The `@specifiedBy` directive is used within the type system definition language to provide a URL for specifying the behavior of custom scalar definitions." +directive @specifiedBy("The specifiedBy URL points to a human-readable specification. This field will only read a result for scalar types." url: String!) on SCALAR + scalar DateTime @specifiedBy(url: "https:\/\/www.graphql-scalars.com\/date-time") "The `Long` scalar type represents non-fractional signed whole 64-bit numeric values. Long can represent values between -(2^63) and 2^63 - 1." diff --git a/frontend/src/lib/app.postcss b/frontend/src/lib/app.postcss index 3ff3408b5..eee688940 100644 --- a/frontend/src/lib/app.postcss +++ b/frontend/src/lib/app.postcss @@ -220,6 +220,7 @@ img[src*="onestory-editor-logo"] { .x-ellipsis { max-width: 100%; overflow: hidden; - overflow-x: clip; text-overflow: ellipsis; + /* Ensures dangling letters are not clipped (e.g. "g") */ + line-height: 1.3em; } diff --git a/frontend/src/lib/components/FilterBar/FilterBar.svelte b/frontend/src/lib/components/FilterBar/FilterBar.svelte index 236e12235..f2ee1f17f 100644 --- a/frontend/src/lib/components/FilterBar/FilterBar.svelte +++ b/frontend/src/lib/components/FilterBar/FilterBar.svelte @@ -86,7 +86,7 @@
- +
✕ {/if} - {#if $$slots.filters} + {#if $$slots.filterSlot}
- +
diff --git a/frontend/src/lib/components/Projects/ProjectFilter.svelte b/frontend/src/lib/components/Projects/ProjectFilter.svelte index e10a78e7d..f82a05cf7 100644 --- a/frontend/src/lib/components/Projects/ProjectFilter.svelte +++ b/frontend/src/lib/components/Projects/ProjectFilter.svelte @@ -66,7 +66,7 @@ - + {#each activeFilters as filter} {#if filter.key === 'projectType'} @@ -108,7 +108,7 @@ {/if} {/each} - +

{$t('project.filter.title')}

{#if filterEnabled('memberSearch')} diff --git a/frontend/src/lib/components/Users/UserFilter.svelte b/frontend/src/lib/components/Users/UserFilter.svelte index f060486c1..1fc55a834 100644 --- a/frontend/src/lib/components/Users/UserFilter.svelte +++ b/frontend/src/lib/components/Users/UserFilter.svelte @@ -57,7 +57,7 @@ {filterKeys} {loading} > - + {#each activeFilters as filter} {#if filter.key === 'userType' && filter.value} @@ -80,7 +80,7 @@ {/if} {/each} - +

{$t('admin_dashboard.user_filter.title')}

{#if filterEnabled('userType')}
diff --git a/frontend/src/lib/components/Users/UserTable.svelte b/frontend/src/lib/components/Users/UserTable.svelte index 6ab1246d8..ebd991cc3 100644 --- a/frontend/src/lib/components/Users/UserTable.svelte +++ b/frontend/src/lib/components/Users/UserTable.svelte @@ -58,7 +58,11 @@ {#if user.username} - {user.username} + + + {user.username} + + {:else} – {/if} diff --git a/frontend/src/lib/forms/RadioButtonGroup.svelte b/frontend/src/lib/forms/RadioButtonGroup.svelte index dfc1a1a2f..7821febb8 100644 --- a/frontend/src/lib/forms/RadioButtonGroup.svelte +++ b/frontend/src/lib/forms/RadioButtonGroup.svelte @@ -19,11 +19,12 @@ export let description: string | undefined = undefined; export let variant: 'radio-warning' | undefined = undefined; export let labelColor: 'text-warning' | undefined = undefined; + export let divClass: string | undefined = undefined;
diff --git a/frontend/src/lib/gql/gql-client.ts b/frontend/src/lib/gql/gql-client.ts index 2b7f0dd75..402356732 100644 --- a/frontend/src/lib/gql/gql-client.ts +++ b/frontend/src/lib/gql/gql-client.ts @@ -1,3 +1,9 @@ +import {browser} from '$app/environment'; +import {tracingExchange} from '$lib/otel'; +import type {LexAuthUser} from '$lib/user'; +import {isRedirect} from '@sveltejs/kit'; +import {devtoolsExchange} from '@urql/devtools'; +import {cacheExchange} from '@urql/exchange-graphcache'; import { type AnyVariables, type Client, @@ -11,36 +17,35 @@ import { queryStore, type TypedDocumentNode } from '@urql/svelte'; -import {browser} from '$app/environment'; +import type {Readable, Unsubscriber} from 'svelte/store'; +import {derived} from 'svelte/store'; import {isObject} from '../util/types'; -import {tracingExchange} from '$lib/otel'; import { type $OpResult, + type CreateOrgMutation, + type CreateProjectMutation, + CreateProjectResult, type ExtractErrorTypename, type GenericData, type GqlInputError, isErrorResult, LexGqlError, + type MutationAddProjectsToOrgArgs, type MutationAddProjectToOrgArgs, - type MutationRemoveProjectFromOrgArgs, - type CreateProjectMutation, - CreateProjectResult, - type MutationSetOrgMemberRoleArgs, - type MutationChangeOrgMemberRoleArgs, - type MutationLeaveProjectArgs, type MutationBulkAddOrgMembersArgs, type MutationBulkAddProjectMembersArgs, + type MutationChangeOrgMemberRoleArgs, type MutationChangeUserAccountBySelfArgs, + type MutationCreateOrganizationArgs, + type MutationCreateProjectArgs, + type MutationDeleteDraftProjectArgs, type MutationDeleteUserByAdminOrSelfArgs, - type MutationDeleteDraftProjectArgs, type MutationSoftDeleteProjectArgs, type MutationCreateProjectArgs, - type MutationAddProjectsToOrgArgs, + type MutationLeaveOrgArgs, + type MutationLeaveProjectArgs, + type MutationRemoveProjectFromOrgArgs, + type MutationSetOrgMemberRoleArgs, + type MutationSoftDeleteProjectArgs, } from './types'; -import type {Readable, Unsubscriber} from 'svelte/store'; -import {derived} from 'svelte/store'; -import {cacheExchange} from '@urql/exchange-graphcache'; -import {devtoolsExchange} from '@urql/devtools'; -import type {LexAuthUser} from '$lib/user'; -import {isRedirect} from '@sveltejs/kit'; let globalClient: GqlClient | null = null; @@ -95,6 +100,9 @@ function createGqlClient(_gqlEndpoint?: string): Client { cache.invalidate({__typename: 'Project', id: args.input.projectId}); } }, + createOrganization: (result: CreateOrgMutation, args: MutationCreateOrganizationArgs, cache, _info) => { + cache.invalidate('Query', 'myOrgs'); + }, addProjectsToOrg: (result, args: MutationAddProjectsToOrgArgs, cache, _info) => { cache.invalidate({__typename: 'OrgById', id: args.input.orgId}); }, @@ -104,11 +112,16 @@ function createGqlClient(_gqlEndpoint?: string): Client { changeOrgMemberRole: (result, args: MutationChangeOrgMemberRoleArgs, cache, _info) => { cache.invalidate({__typename: 'OrgById', id: args.input.orgId}); }, + leaveOrg: (result, args: MutationLeaveOrgArgs, cache, _info) => { + cache.invalidate({__typename: 'OrgById', id: args.input.orgId}); + cache.invalidate('Query', 'myOrgs'); + }, setOrgMemberRole: (result, args: MutationSetOrgMemberRoleArgs, cache, _info) => { cache.invalidate({__typename: 'OrgById', id: args.input.orgId}); }, leaveProject: (result, args: MutationLeaveProjectArgs, cache, _info) => { cache.invalidate({__typename: 'Project', id: args.input.projectId}); + cache.invalidate('Query', 'myProjects'); }, addProjectToOrg: (result, args: MutationAddProjectToOrgArgs, cache, _info) => { cache.invalidate({__typename: 'Project', id: args.input.projectId}, 'organizations'); diff --git a/frontend/src/lib/i18n/locales/en.json b/frontend/src/lib/i18n/locales/en.json index 7df8cdb59..8ba0edbc7 100644 --- a/frontend/src/lib/i18n/locales/en.json +++ b/frontend/src/lib/i18n/locales/en.json @@ -83,7 +83,7 @@ }, "reset_password": "Reset your password instead?", "update_success": "Your account has been updated.", - "button_update": "Update account info", + "button_update": "Update Account Info", "email_taken": "An account with this email address already exists", "delete_account": { "title": "Delete Account", @@ -174,13 +174,18 @@ Lexbox is free and [open source](https://github.com/sillsdev/languageforge-lexbo "title": "Organizations", "name": "Name", "created_at": "Created", - "members": "Members" + "members": "Members", + "my_orgs": "My organizations", + "other_orgs": "Other organizations", + "not_in_any_orgs": "You aren't a member of any organizations yet.", + "no_orgs_found": "No organizations match your filter.", }, "create": { "title": "Create Organization", "name": "Name", "name_missing": "Organization name required", "submit": "Create Organization", + "name_exists": "An organization with this name already exists", } }, "project": { @@ -279,7 +284,7 @@ Lexbox is free and [open source](https://github.com/sillsdev/languageforge-lexbo }, "add_my_projects": { "title": "Add projects I manage to this organization", - "open_button": "Add my projects", + "open_button": "Add My Projects", "submit_button": "Add Projects", "all_projects_already_added": "The {count, plural, one {# project} other {# projects}} you manage {count, plural, one {is} other {are}} already in this organization", "no_projects_managed": "You don't manage any projects", @@ -319,15 +324,29 @@ Lexbox is free and [open source](https://github.com/sillsdev/languageforge-lexbo "rename_org": "Organization name set to {name}.", "delete_org": "Organization {name} has been deleted.", "remove_project_from_org": "You have successfully removed {projectName} from this organization", - "leave_org": "You have left the organization {name}.", - "leave_org_error": "An error occurred trying to remove you from organization {name}. Please try again later.", "describe": "Organization description has been updated.", "add_member": "{email} has been added to organization.", "member_invited": "{email} has been sent an invitation email to register and join the organization.", "added_projects": "Added {count, plural, one {# project} other {# projects}} to organization", }, + "leave": { + "confirm_title": "Leave organization?", + "leave_action": "Leave", + "dont_leave": "Don't leave", + "confirm_leave": "Are you sure you want to leave this organization?", + "last_to_leave": "You are the last admin of this organization. To leave you need to delete the organization, or add another admin.", + "success": "You have left the organization {name}.", + "leave_org": "Leave organization", + "error": "An error occurred trying to remove you from this organization. Please try again later.", + }, + "remove_member": { + "remove": "Remove", + "member": "Member", + "confirm_message": "Would you like to remove {memberName} from this organization?", + "success": "You have successfully removed {memberName} from this organization.", + "error": "An error occurred trying to remove {memberName} from this organization. Please try again later.", + }, "edit_member_role": "Change Role", - "remove_member": "Remove", "org_name_empty_error": "Organization name cannot be empty", "projects_table_title": "Projects", "members_table_title": "Members", @@ -394,7 +413,7 @@ Lexbox is free and [open source](https://github.com/sillsdev/languageforge-lexbo "next": "Next", "back": "Back", "download_instruction": "First, download a backup of the project that you can use to restore it in step 3:", - "download_button": "Download project backup", + "download_button": "Download Project Backup", "confirm_downloaded": "I confirm that I have downloaded a backup of the project and verified that it works. I am ready to completely reset/delete the contents of the project repository.", "confirm_downloaded_error": "Please confirm you have downloaded a backup", "confirm_project_code": "Enter project code to confirm reset", @@ -639,7 +658,7 @@ If you don't see a dialog or already closed it, click the button below:", "forgot_password": { "subject": "Forgot your password?", "to_reset_click": "Click the button below to reset your password.", - "reset_button": "Reset password" + "reset_button": "Reset Password" }, "password_changed": { "subject": "Your password was changed", @@ -652,12 +671,12 @@ If you don't see a dialog or already closed it, click the button below:", "create_account_request_email_project": { "subject": "Project invitation: {projectName}", "body": "{managerName} has invited you to join the project: {projectName}. Click below to join.", - "join_button": "Join project" + "join_button": "Join Project" }, "create_account_request_email_org": { "subject": "Organization invitation: {orgName}", "body": "{managerName} has invited you to join the organization: {orgName}. Click below to join.", - "join_button": "Join organization" + "join_button": "Join Organization" }, "create_project_request_email": { "subject": "Project request: {projectName}", @@ -666,17 +685,17 @@ If you don't see a dialog or already closed it, click the button below:", "join_project_request_email": { "subject": "Project join request: {requestingUserName} wants to join {projectName}", "body": "User {requestingUserName} requested to join the project {projectName}. Click below to approve this request.", - "approve_button": "Approve request" + "approve_button": "Approve Request" }, "approve_project_request_email": { "subject": "Project approved: {projectName}", "heading": "The project you requested, {projectName}, has been approved and created.", - "view_button": "View project" + "view_button": "View Project" }, "user_added": { "subject": "You joined project: {projectName}!", "body": "You have been added to the project: {projectName}.", - "view_button": "View project" + "view_button": "View Project" } }, "footer": { diff --git a/frontend/src/lib/icons/Icon.svelte b/frontend/src/lib/icons/Icon.svelte index 435f41abc..99fa52775 100644 --- a/frontend/src/lib/icons/Icon.svelte +++ b/frontend/src/lib/icons/Icon.svelte @@ -10,8 +10,12 @@ export let size: IconSize = 'text-lg'; export let color: `text-${string}` | undefined = undefined; export let pale = false; + // For pixel perfect text alignment, because the svgs often contain vertical white-space + export let y: string | undefined = undefined; + + $: transform = y ? `translateY(${y})` : ''; {#if icon} - + {/if} diff --git a/frontend/src/lib/layout/Breadcrumbs/Breadcrumbs.svelte b/frontend/src/lib/layout/Breadcrumbs/Breadcrumbs.svelte index e6b3d3f5e..fccfca6d6 100644 --- a/frontend/src/lib/layout/Breadcrumbs/Breadcrumbs.svelte +++ b/frontend/src/lib/layout/Breadcrumbs/Breadcrumbs.svelte @@ -7,15 +7,21 @@ -
- {$t('about.title')} + {$t('about.title')} {$t('footer.terms_of_use')} {$t('footer.contact')} {$t('footer.privacy_policy')} diff --git a/frontend/src/lib/layout/HeaderPage.svelte b/frontend/src/lib/layout/HeaderPage.svelte index 7b2b2801d..7d1e9cd31 100644 --- a/frontend/src/lib/layout/HeaderPage.svelte +++ b/frontend/src/lib/layout/HeaderPage.svelte @@ -1,27 +1,27 @@ - +
-
+
-

+

{#if $$slots.title} {:else} - {title} + {titleText} {/if}

- +
diff --git a/frontend/src/lib/layout/Layout.svelte b/frontend/src/lib/layout/Layout.svelte index b0857845b..6abfb5d42 100644 --- a/frontend/src/lib/layout/Layout.svelte +++ b/frontend/src/lib/layout/Layout.svelte @@ -44,7 +44,7 @@ {#if !hideToolbar}
-
+
@@ -55,19 +55,21 @@ {$t('appmenu.help')} - + + {$t('appmenu.orgs')} + - + {$t('user_dashboard.title')} - + {$t('admin_dashboard.title')} diff --git a/frontend/src/routes/(authenticated)/+page.svelte b/frontend/src/routes/(authenticated)/+page.svelte index 61ace8c55..28aabedd4 100644 --- a/frontend/src/routes/(authenticated)/+page.svelte +++ b/frontend/src/routes/(authenticated)/+page.svelte @@ -59,8 +59,8 @@ } - - + +
-