Browse Source

Custom OIDC (#1090)

* Fix JSON.

* Simplify json.

* Custom OIDC server.

* Fix tests

* Delete team and more tests.
pull/1092/head
Sebastian Stehle 2 years ago
committed by GitHub
parent
commit
c945c5b475
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 13
      .github/workflows/dev.yml
  2. 6
      .github/workflows/release.yml
  3. 8
      backend/extensions/Squidex.Extensions/Actions/CreateContent/CreateContentActionHandler.cs
  4. 8
      backend/extensions/Squidex.Extensions/Actions/Notification/NotificationActionHandler.cs
  5. 22
      backend/extensions/Squidex.Extensions/Squidex.Extensions.csproj
  6. 30
      backend/i18n/frontend_en.json
  7. 24
      backend/i18n/frontend_fr.json
  8. 28
      backend/i18n/frontend_it.json
  9. 24
      backend/i18n/frontend_nl.json
  10. 22
      backend/i18n/frontend_pt.json
  11. 30
      backend/i18n/frontend_zh.json
  12. 13
      backend/i18n/source/backend_en.json
  13. 2
      backend/i18n/source/backend_fr.json
  14. 2
      backend/i18n/source/backend_it.json
  15. 2
      backend/i18n/source/backend_zh.json
  16. 30
      backend/i18n/source/frontend_en.json
  17. 2
      backend/i18n/source/frontend_fr.json
  18. 2
      backend/i18n/source/frontend_zh.json
  19. 6
      backend/i18n/translator/Squidex.Translator/Processes/Helper.cs
  20. 2
      backend/src/Migrations/Migrations.csproj
  21. 2
      backend/src/Squidex.Domain.Apps.Core.Model/Squidex.Domain.Apps.Core.Model.csproj
  22. 23
      backend/src/Squidex.Domain.Apps.Core.Model/Teams/AuthScheme.cs
  23. 15
      backend/src/Squidex.Domain.Apps.Core.Model/Teams/Team.cs
  24. 6
      backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/Parser.cs
  25. 4
      backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptExecutionContext.cs
  26. 6
      backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj
  27. 4
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Squidex.Domain.Apps.Entities.MongoDb.csproj
  28. 11
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Teams/MongoTeamEntity.cs
  29. 17
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Teams/MongoTeamRepository.cs
  30. 20
      backend/src/Squidex.Domain.Apps.Entities/AppProvider.cs
  31. 2
      backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/Guards/GuardAppClients.cs
  32. 3
      backend/src/Squidex.Domain.Apps.Entities/IAppProvider.cs
  33. 4
      backend/src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj
  34. 12
      backend/src/Squidex.Domain.Apps.Entities/Teams/Commands/DeleteTeam.cs
  35. 15
      backend/src/Squidex.Domain.Apps.Entities/Teams/Commands/UpsertAuth.cs
  36. 76
      backend/src/Squidex.Domain.Apps.Entities/Teams/DomainObject/Guards/GuardTeam.cs
  37. 9
      backend/src/Squidex.Domain.Apps.Entities/Teams/DomainObject/TeamDomainObject.State.cs
  38. 35
      backend/src/Squidex.Domain.Apps.Entities/Teams/DomainObject/TeamDomainObject.cs
  39. 3
      backend/src/Squidex.Domain.Apps.Entities/Teams/Indexes/ITeamsIndex.cs
  40. 17
      backend/src/Squidex.Domain.Apps.Entities/Teams/Indexes/TeamsIndex.cs
  41. 3
      backend/src/Squidex.Domain.Apps.Entities/Teams/Repositories/ITeamRepository.cs
  42. 2
      backend/src/Squidex.Domain.Apps.Events/Squidex.Domain.Apps.Events.csproj
  43. 15
      backend/src/Squidex.Domain.Apps.Events/Teams/TeamAuthChanged.cs
  44. 12
      backend/src/Squidex.Domain.Apps.Events/Teams/TeamDeleted.cs
  45. 4
      backend/src/Squidex.Domain.Users.MongoDb/Squidex.Domain.Users.MongoDb.csproj
  46. 8
      backend/src/Squidex.Domain.Users/Squidex.Domain.Users.csproj
  47. 4
      backend/src/Squidex.Infrastructure.GetEventStore/Squidex.Infrastructure.GetEventStore.csproj
  48. 6
      backend/src/Squidex.Infrastructure.MongoDb/Squidex.Infrastructure.MongoDb.csproj
  49. 4
      backend/src/Squidex.Infrastructure/Queries/OData/PropertyPathVisitor.cs
  50. 10
      backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj
  51. 10
      backend/src/Squidex.Infrastructure/Validation/Not.cs
  52. 6
      backend/src/Squidex.Shared/PermissionIds.cs
  53. 2
      backend/src/Squidex.Shared/Squidex.Shared.csproj
  54. 38
      backend/src/Squidex.Shared/Texts.fr.resx
  55. 38
      backend/src/Squidex.Shared/Texts.it.resx
  56. 38
      backend/src/Squidex.Shared/Texts.nl.resx
  57. 36
      backend/src/Squidex.Shared/Texts.pt.resx
  58. 38
      backend/src/Squidex.Shared/Texts.resx
  59. 38
      backend/src/Squidex.Shared/Texts.zh.resx
  60. 3
      backend/src/Squidex.Web/Resources.cs
  61. 2
      backend/src/Squidex.Web/Squidex.Web.csproj
  62. 62
      backend/src/Squidex/Areas/Api/Controllers/Teams/Models/AuthSchemeDto.cs
  63. 46
      backend/src/Squidex/Areas/Api/Controllers/Teams/Models/AuthSchemeResponseDto.cs
  64. 39
      backend/src/Squidex/Areas/Api/Controllers/Teams/Models/AuthSchemeValueDto.cs
  65. 12
      backend/src/Squidex/Areas/Api/Controllers/Teams/Models/TeamDto.cs
  66. 76
      backend/src/Squidex/Areas/Api/Controllers/Teams/TeamsController.cs
  67. 2
      backend/src/Squidex/Areas/Api/Views/Shared/Docs.cshtml
  68. 20
      backend/src/Squidex/Areas/IdentityServer/Config/Dynamic/DynamicOpenIdConnectHandler.cs
  69. 14
      backend/src/Squidex/Areas/IdentityServer/Config/Dynamic/DynamicOpenIdConnectOptions.cs
  70. 236
      backend/src/Squidex/Areas/IdentityServer/Config/Dynamic/DynamicSchemeProvider.cs
  71. 7
      backend/src/Squidex/Areas/IdentityServer/Config/IdentityServerServices.cs
  72. 61
      backend/src/Squidex/Areas/IdentityServer/Controllers/Account/AccountController.cs
  73. 16
      backend/src/Squidex/Areas/IdentityServer/Controllers/Account/LoginDynamicModel.cs
  74. 21
      backend/src/Squidex/Areas/IdentityServer/Controllers/Account/LoginVM.cs
  75. 37
      backend/src/Squidex/Areas/IdentityServer/Controllers/Error/ErrorController.cs
  76. 6
      backend/src/Squidex/Areas/IdentityServer/Controllers/Profile/ProfileVM.cs
  77. 4
      backend/src/Squidex/Areas/IdentityServer/Controllers/Setup/SetupVM.cs
  78. 40
      backend/src/Squidex/Areas/IdentityServer/Controllers/Test/TestController.cs
  79. 4
      backend/src/Squidex/Areas/IdentityServer/Views/Account/Consent.cshtml
  80. 76
      backend/src/Squidex/Areas/IdentityServer/Views/Account/Login.cshtml
  81. 4
      backend/src/Squidex/Areas/IdentityServer/Views/Error/Error.cshtml
  82. 95
      backend/src/Squidex/Areas/IdentityServer/Views/Profile/Profile.cshtml
  83. 111
      backend/src/Squidex/Areas/IdentityServer/Views/Setup/Setup.cshtml
  84. 13
      backend/src/Squidex/Areas/IdentityServer/Views/Test/Success.cshtml
  85. 58
      backend/src/Squidex/Areas/IdentityServer/Views/ValidationPageHelper.cs
  86. 1
      backend/src/Squidex/Areas/IdentityServer/Views/_ViewImports.cshtml
  87. 4
      backend/src/Squidex/Config/MyIdentityOptions.cs
  88. 39
      backend/src/Squidex/Squidex.csproj
  89. 2
      backend/src/Squidex/Startup.cs
  90. 3
      backend/src/Squidex/appsettings.json
  91. 8
      backend/src/Squidex/wwwroot/editor/squidex-editor.js
  92. 9
      backend/tests/Squidex.Domain.Apps.Core.Tests/Model/Teams/Team.json
  93. 15
      backend/tests/Squidex.Domain.Apps.Core.Tests/Model/Teams/TeamTests.cs
  94. 8
      backend/tests/Squidex.Domain.Apps.Core.Tests/Squidex.Domain.Apps.Core.Tests.csproj
  95. 11
      backend/tests/Squidex.Domain.Apps.Entities.Tests/AppProviderTests.cs
  96. 2
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/DomainObject/AppDomainObjectTests.cs
  97. 8
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/MigrateFieldNamesCommandMiddlewareTests.cs
  98. 10
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Squidex.Domain.Apps.Entities.Tests.csproj
  99. 120
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Teams/DomainObject/Guards/GuardTeamTests.cs
  100. 60
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Teams/DomainObject/TeamDomainObjectTests.cs

13
.github/workflows/dev.yml

@ -48,7 +48,7 @@ jobs:
- name: Test - RUN
uses: kohlerdominik/docker-run-action@v2.0.0
with:
image: squidex/build:8
image: mcr.microsoft.com/dotnet/sdk:8.0
environment: |
CONFIG__BACKUPURL=http://localhost:5000
CONFIG__WAIT=60
@ -62,7 +62,7 @@ jobs:
- name: Test - RUN on path
uses: kohlerdominik/docker-run-action@v2.0.0
with:
image: squidex/build:8
image: mcr.microsoft.com/dotnet/sdk:8.0
environment: |
CONFIG__BACKUPURL=http://localhost:5000
CONFIG__WAIT=60
@ -76,7 +76,7 @@ jobs:
- name: Test - RUN with dedicated collections
uses: kohlerdominik/docker-run-action@v2.0.0
with:
image: squidex/build:8
image: mcr.microsoft.com/dotnet/sdk:8.0
environment: |
CONFIG__BACKUPURL=http://localhost:5000
CONFIG__WAIT=60
@ -109,6 +109,13 @@ jobs:
path: tools/e2e/playwright-report/
retention-days: 30
- name: Test - Upload Screenshots
if: failure()
uses: actions/upload-artifact@v4.3.1
with:
path: |
tools/TestSuite/TestSuite.ApiTests/bin/Debug/net8.0/screenshots/
- name: Test - Dump docker logs on failure
if: failure()
uses: jwalton/gh-docker-logs@v2.2.2

6
.github/workflows/release.yml

@ -43,7 +43,7 @@ jobs:
- name: Test - RUN
uses: kohlerdominik/docker-run-action@v2.0.0
with:
image: squidex/build:8
image: mcr.microsoft.com/dotnet/sdk:8.0
environment: |
CONFIG__BACKUPURL=http://localhost:5000
CONFIG__WAIT=60
@ -57,7 +57,7 @@ jobs:
- name: Test - RUN on path
uses: kohlerdominik/docker-run-action@v2.0.0
with:
image: squidex/build:8
image: mcr.microsoft.com/dotnet/sdk:8.0
environment: |
CONFIG__BACKUPURL=http://localhost:5000
CONFIG__WAIT=60
@ -71,7 +71,7 @@ jobs:
- name: Test - RUN with dedicated collections
uses: kohlerdominik/docker-run-action@v2.0.0
with:
image: squidex/build:8
image: squidex/build:9
environment: |
CONFIG__BACKUPURL=http://localhost:5000
CONFIG__WAIT=60

8
backend/extensions/Squidex.Extensions/Actions/CreateContent/CreateContentActionHandler.cs

@ -39,12 +39,8 @@ public sealed class CreateContentActionHandler : RuleActionHandler<CreateContent
AppId = @event.AppId
};
var schema = await appProvider.GetSchemaAsync(@event.AppId.Id, action.Schema, true);
if (schema == null)
{
throw new InvalidOperationException($"Cannot find schema '{action.Schema}'");
}
var schema = await appProvider.GetSchemaAsync(@event.AppId.Id, action.Schema, true)
?? throw new InvalidOperationException($"Cannot find schema '{action.Schema}'");
ruleJob.SchemaId = schema.NamedId();
ruleJob.FromRule = true;

8
backend/extensions/Squidex.Extensions/Actions/Notification/NotificationActionHandler.cs

@ -34,12 +34,8 @@ public sealed class NotificationActionHandler : RuleActionHandler<NotificationAc
return ("Ignore", new CommentCreated());
}
var user = await userResolver.FindByIdOrEmailAsync(action.User);
if (user == null)
{
throw new InvalidOperationException($"Cannot find user by '{action.User}'");
}
var user = await userResolver.FindByIdOrEmailAsync(action.User)
?? throw new InvalidOperationException($"Cannot find user by '{action.User}'");
var actor = userEvent.Actor;

22
backend/extensions/Squidex.Extensions/Squidex.Extensions.csproj

@ -13,27 +13,27 @@
<PackageReference Include="Algolia.Search" Version="6.17.0" />
<PackageReference Include="Azure.Monitor.OpenTelemetry.Exporter" Version="1.2.0" />
<PackageReference Include="Azure.Search.Documents" Version="11.5.1" />
<PackageReference Include="Confluent.SchemaRegistry.Serdes.Avro" Version="2.3.0" />
<PackageReference Include="Confluent.SchemaRegistry.Serdes.Avro" Version="2.4.0" />
<PackageReference Include="CoreTweet" Version="1.0.0.483" />
<PackageReference Include="Elasticsearch.Net" Version="7.17.5" />
<PackageReference Include="Google.Cloud.Diagnostics.Common" Version="5.2.0" />
<PackageReference Include="Google.Cloud.Logging.V2" Version="4.2.0" />
<PackageReference Include="Google.Cloud.Monitoring.V3" Version="3.6.0" />
<PackageReference Include="Meziantou.Analyzer" Version="2.0.146">
<PackageReference Include="Google.Cloud.Logging.V2" Version="4.3.0" />
<PackageReference Include="Google.Cloud.Monitoring.V3" Version="3.9.0" />
<PackageReference Include="Meziantou.Analyzer" Version="2.0.150">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Azure.CognitiveServices.Vision.ComputerVision" Version="7.0.1" />
<PackageReference Include="Microsoft.Azure.SignalR.Management" Version="1.25.1" />
<PackageReference Include="Microsoft.Azure.SignalR.Management" Version="1.25.2" />
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.0" />
<PackageReference Include="Microsoft.OData.Core" Version="7.20.0" />
<PackageReference Include="Microsoft.OData.Core" Version="7.21.1" />
<PackageReference Include="NodaTime" Version="3.1.11" />
<PackageReference Include="OpenSearch.Net" Version="1.7.0" />
<PackageReference Include="OpenTelemetry.Exporter.Console" Version="1.7.0" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.7.0" />
<PackageReference Include="OpenTelemetry.Exporter.Zipkin" Version="1.7.0" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.7.0" />
<PackageReference Include="OpenSearch.Net" Version="1.7.1" />
<PackageReference Include="OpenTelemetry.Exporter.Console" Version="1.8.1" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.8.1" />
<PackageReference Include="OpenTelemetry.Exporter.Zipkin" Version="1.8.1" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.8.1" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="Squidex.OpenTelemetry.Exporter.Stackdriver" Version="0.0.0-alpha.0.395" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />

30
backend/i18n/frontend_en.json

@ -17,16 +17,16 @@
"apps.appsButtonCreate": "Create App",
"apps.appsButtonCreateTeam": "Create Team",
"apps.appsButtonFallbackTitle": "Apps and Teams",
"apps.archiveFailed": "Failed to archive app.",
"apps.archiveFailed": "Failed to archive App.",
"apps.create": "Create App",
"apps.createBlankApp": "New App",
"apps.createBlankAppDescription": "Create a new blank app without content and schemas.",
"apps.createFailed": "Failed to create app. Please reload.",
"apps.createWithTemplate": "Create {template} Sample",
"apps.delete": "Delete App",
"apps.deleteConfirmText": "Do you really want to delete this app?",
"apps.deleteConfirmText": "Do you really want to delete this App?",
"apps.deleteConfirmTitle": "I understand. Delete my App",
"apps.deleteWarning": "Once you delete an app, there is no going back. All your data will be deleted in the background.",
"apps.deleteWarning": "Once you delete an App, there is no going back. All your data will be deleted in the background.",
"apps.empty": "You are not collaborating on any apps yet",
"apps.generalSettings": "General",
"apps.generalSettingsDangerZone": "Danger Zone",
@ -200,6 +200,7 @@
"common.aspectRatio": "AspectRatio",
"common.assets": "Assets",
"common.assetScripts": "Asset Scripts",
"common.auth": "Authentication",
"common.back": "Back",
"common.backendError": "Backend ERROR",
"common.backups": "Backups",
@ -1038,9 +1039,30 @@
"start.login": "Login to Squidex",
"start.loginHint": "The login button will open a new popup. Once you are logged in successful we will redirect you to the Squidex management portal.",
"start.madeBy": "Proudly made by",
"start.madeByCopyright": "Sebastian Stehle and Contributors, 2016-2021",
"start.madeByCopyright": "Sebastian Stehle and Contributors, 2016-2024",
"teams.archiveFailed": "Failed to archive Team.",
"teams.auth.authority": "Authority",
"teams.auth.authorityHint": "The URL to your authority server.",
"teams.auth.clientId": "Client ID",
"teams.auth.clientSecret": "Client Secret",
"teams.auth.displayName": "Display Name",
"teams.auth.displayNameHint": "The name for buttons and other UI elements.",
"teams.auth.domain": "Domain",
"teams.auth.domainHint": "The domain is used to identity your users. When they enter their email address and the domain matches to your settings they will redirected to your authentication server.",
"teams.auth.domainHintEmail": "Email Format",
"teams.auth.redirectUrl": "Redirect URL",
"teams.auth.redirectUrlHint": "You have to allow this URL in your authentication server.",
"teams.auth.reloaded": "Auth setting reloaded.",
"teams.auth.signoutRedirectUrl": "Signout Redirect URL",
"teams.auth.testLogin": "Test Login",
"teams.auth.use": "Custom OIDC Server",
"teams.auth.useHint": "Use single-sign-on (SSO) solution to connect Squidex to your infrastructure. Your employees and collegues can connect with their normal business account.",
"teams.create": "Create",
"teams.createFailed": "Failed to create team. Please reload.",
"teams.delete": "Delete Team",
"teams.deleteConfirmText": "Do you really want to delete this Team?",
"teams.deleteConfirmTitle": "I understand. Delete my Team",
"teams.deleteWarning": "Once you delete an Team, there is no going back. All your data will be deleted in the background.",
"teams.empty": "This team has no apps yet.",
"teams.leave": "Leave team",
"teams.leaveConfirmText": "Do you really want to leave this team?",

24
backend/i18n/frontend_fr.json

@ -200,6 +200,7 @@
"common.aspectRatio": "Ratio d'aspect",
"common.assets": "Actifs",
"common.assetScripts": "Scripts d'actif",
"common.auth": "Authentication",
"common.back": "Dos",
"common.backendError": "ERREUR back-end",
"common.backups": "Sauvegardes",
@ -1038,9 +1039,30 @@
"start.login": "Connectez-vous à Squidex",
"start.loginHint": "Le bouton de connexion ouvrira une nouvelle fenêtre contextuelle. Une fois que vous êtes connecté avec succès, nous vous redirigerons vers le portail de gestion Squidex.",
"start.madeBy": "Fièrement fabriqué par",
"start.madeByCopyright": "Sebastian Stehle et contributeurs, 2016-2021",
"start.madeByCopyright": "Sebastian Stehle et contributeurs, 2016-2024",
"teams.archiveFailed": "Failed to archive Team.",
"teams.auth.authority": "Authority",
"teams.auth.authorityHint": "The URL to your authority server.",
"teams.auth.clientId": "Client ID",
"teams.auth.clientSecret": "Client Secret",
"teams.auth.displayName": "Display Name",
"teams.auth.displayNameHint": "The name for buttons and other UI elements.",
"teams.auth.domain": "Domain",
"teams.auth.domainHint": "The domain is used to identity your users. When they enter their email address and the domain matches to your settings they will redirected to your authentication server.",
"teams.auth.domainHintEmail": "Email Format",
"teams.auth.redirectUrl": "Redirect URL",
"teams.auth.redirectUrlHint": "You have to allow this URL in your authentication server.",
"teams.auth.reloaded": "Auth setting reloaded.",
"teams.auth.signoutRedirectUrl": "Signout Redirect URL",
"teams.auth.testLogin": "Test Login",
"teams.auth.use": "Custom OIDC Server",
"teams.auth.useHint": "Use single-sign-on (SSO) solution to connect Squidex to your infrastructure. Your employees and collegues can connect with their normal business account.",
"teams.create": "Créer",
"teams.createFailed": "Échec de la création de l'équipe. Veuillez recharger.",
"teams.delete": "Delete Team",
"teams.deleteConfirmText": "Do you really want to delete this Team?",
"teams.deleteConfirmTitle": "I understand. Delete my Team",
"teams.deleteWarning": "Once you delete an Team, there is no going back. All your data will be deleted in the background.",
"teams.empty": "Cette équipe n'a pas encore d'applications.",
"teams.leave": "Quitter l'équipe",
"teams.leaveConfirmText": "Voulez-vous vraiment quitter cette équipe ?",

28
backend/i18n/frontend_it.json

@ -17,16 +17,16 @@
"apps.appsButtonCreate": "Nuova App",
"apps.appsButtonCreateTeam": "Create Team",
"apps.appsButtonFallbackTitle": "Lista App",
"apps.archiveFailed": "Failed to archive app.",
"apps.archiveFailed": "Failed to archive App.",
"apps.create": "Crea un'App",
"apps.createBlankApp": "Nuova App.",
"apps.createBlankAppDescription": "Crea una app vuota senza contenuti o schema.",
"apps.createFailed": "Non è stato possibile creare l'app. Per favore ricarica.",
"apps.createWithTemplate": "Create un esempio di {template}",
"apps.delete": "Delete App",
"apps.deleteConfirmText": "Do you really want to delete this app?",
"apps.deleteConfirmText": "Do you really want to delete this App?",
"apps.deleteConfirmTitle": "I understand. Delete my App",
"apps.deleteWarning": "Once you delete an app, there is no going back. All your data will be deleted in the background.",
"apps.deleteWarning": "Once you delete an App, there is no going back. All your data will be deleted in the background.",
"apps.empty": "Non stai ancora collaborando su nessuna app",
"apps.generalSettings": "Generale",
"apps.generalSettingsDangerZone": "Generale",
@ -200,6 +200,7 @@
"common.aspectRatio": "Proporzioni",
"common.assets": "Risorse",
"common.assetScripts": "Asset Scripts",
"common.auth": "Authentication",
"common.back": "Indietro",
"common.backendError": "Errore nel Backend",
"common.backups": "Backup",
@ -1039,8 +1040,29 @@
"start.loginHint": "Il pulsante per accedere aprirà un popup. Una volta effettuato l'accesso sarai indirizzato al portale per la gestione di Squidex.",
"start.madeBy": "Realizzato con orgoglio da",
"start.madeByCopyright": "Sebastian Stehle e Collaboratori, 2016-2020",
"teams.archiveFailed": "Failed to archive Team.",
"teams.auth.authority": "Authority",
"teams.auth.authorityHint": "The URL to your authority server.",
"teams.auth.clientId": "Client ID",
"teams.auth.clientSecret": "Client Secret",
"teams.auth.displayName": "Display Name",
"teams.auth.displayNameHint": "The name for buttons and other UI elements.",
"teams.auth.domain": "Domain",
"teams.auth.domainHint": "The domain is used to identity your users. When they enter their email address and the domain matches to your settings they will redirected to your authentication server.",
"teams.auth.domainHintEmail": "Email Format",
"teams.auth.redirectUrl": "Redirect URL",
"teams.auth.redirectUrlHint": "You have to allow this URL in your authentication server.",
"teams.auth.reloaded": "Auth setting reloaded.",
"teams.auth.signoutRedirectUrl": "Signout Redirect URL",
"teams.auth.testLogin": "Test Login",
"teams.auth.use": "Custom OIDC Server",
"teams.auth.useHint": "Use single-sign-on (SSO) solution to connect Squidex to your infrastructure. Your employees and collegues can connect with their normal business account.",
"teams.create": "Create",
"teams.createFailed": "Failed to create team. Please reload.",
"teams.delete": "Delete Team",
"teams.deleteConfirmText": "Do you really want to delete this Team?",
"teams.deleteConfirmTitle": "I understand. Delete my Team",
"teams.deleteWarning": "Once you delete an Team, there is no going back. All your data will be deleted in the background.",
"teams.empty": "This team has no apps yet.",
"teams.leave": "Leave team",
"teams.leaveConfirmText": "Do you really want to leave this team?",

24
backend/i18n/frontend_nl.json

@ -17,7 +17,7 @@
"apps.appsButtonCreate": "Apps-overzicht",
"apps.appsButtonCreateTeam": "Create Team",
"apps.appsButtonFallbackTitle": "Apps-overzicht",
"apps.archiveFailed": "Failed to archive app.",
"apps.archiveFailed": "Failed to archive App.",
"apps.create": "App maken",
"apps.createBlankApp": "Nieuwe app.",
"apps.createBlankAppDescription": "Maak een nieuwe lege app zonder inhoud en schema's.",
@ -200,6 +200,7 @@
"common.aspectRatio": "AspectRatio",
"common.assets": "Bestanden",
"common.assetScripts": "Asset Scripts",
"common.auth": "Authentication",
"common.back": "Terug",
"common.backendError": "Backend ERROR",
"common.backups": "Back-ups",
@ -1039,8 +1040,29 @@
"start.loginHint": "De login-knop opent een nieuwe pop-up. Zodra je succesvol bent ingelogd, zullen we je doorverwijzen naar het Squidex beheerportaal.",
"start.madeBy": "Met trots gemaakt door",
"start.madeByCopyright": "Sebastian Stehle en medewerkers, 2016-2020",
"teams.archiveFailed": "Failed to archive Team.",
"teams.auth.authority": "Authority",
"teams.auth.authorityHint": "The URL to your authority server.",
"teams.auth.clientId": "Client ID",
"teams.auth.clientSecret": "Client Secret",
"teams.auth.displayName": "Display Name",
"teams.auth.displayNameHint": "The name for buttons and other UI elements.",
"teams.auth.domain": "Domain",
"teams.auth.domainHint": "The domain is used to identity your users. When they enter their email address and the domain matches to your settings they will redirected to your authentication server.",
"teams.auth.domainHintEmail": "Email Format",
"teams.auth.redirectUrl": "Redirect URL",
"teams.auth.redirectUrlHint": "You have to allow this URL in your authentication server.",
"teams.auth.reloaded": "Auth setting reloaded.",
"teams.auth.signoutRedirectUrl": "Signout Redirect URL",
"teams.auth.testLogin": "Test Login",
"teams.auth.use": "Custom OIDC Server",
"teams.auth.useHint": "Use single-sign-on (SSO) solution to connect Squidex to your infrastructure. Your employees and collegues can connect with their normal business account.",
"teams.create": "Create",
"teams.createFailed": "Failed to create team. Please reload.",
"teams.delete": "Delete Team",
"teams.deleteConfirmText": "Do you really want to delete this Team?",
"teams.deleteConfirmTitle": "I understand. Delete my Team",
"teams.deleteWarning": "Once you delete an Team, there is no going back. All your data will be deleted in the background.",
"teams.empty": "This team has no apps yet.",
"teams.leave": "Leave team",
"teams.leaveConfirmText": "Do you really want to leave this team?",

22
backend/i18n/frontend_pt.json

@ -200,6 +200,7 @@
"common.aspectRatio": "AspectRatio",
"common.assets": "Ficheiros",
"common.assetScripts": "Scripts de ficheiros",
"common.auth": "Authentication",
"common.back": "Voltar",
"common.backendError": "Erro de backend",
"common.backups": "Backups",
@ -1039,8 +1040,29 @@
"start.loginHint": "O botão de login abrirá um novo pop-up. Assim que tiver sucesso, redireciona-o para o portal de gestão Squidex.",
"start.madeBy": "Orgulhosamente feito por",
"start.madeByCopyright": "Sebastian Stehle e Colaboradores, 2016-2022",
"teams.archiveFailed": "Failed to archive Team.",
"teams.auth.authority": "Authority",
"teams.auth.authorityHint": "The URL to your authority server.",
"teams.auth.clientId": "Client ID",
"teams.auth.clientSecret": "Client Secret",
"teams.auth.displayName": "Display Name",
"teams.auth.displayNameHint": "The name for buttons and other UI elements.",
"teams.auth.domain": "Domain",
"teams.auth.domainHint": "The domain is used to identity your users. When they enter their email address and the domain matches to your settings they will redirected to your authentication server.",
"teams.auth.domainHintEmail": "Email Format",
"teams.auth.redirectUrl": "Redirect URL",
"teams.auth.redirectUrlHint": "You have to allow this URL in your authentication server.",
"teams.auth.reloaded": "Auth setting reloaded.",
"teams.auth.signoutRedirectUrl": "Signout Redirect URL",
"teams.auth.testLogin": "Test Login",
"teams.auth.use": "Custom OIDC Server",
"teams.auth.useHint": "Use single-sign-on (SSO) solution to connect Squidex to your infrastructure. Your employees and collegues can connect with their normal business account.",
"teams.create": "Criar",
"teams.createFailed": "Falhou na criação de uma equipa. Por favor, recarregue.",
"teams.delete": "Delete Team",
"teams.deleteConfirmText": "Do you really want to delete this Team?",
"teams.deleteConfirmTitle": "I understand. Delete my Team",
"teams.deleteWarning": "Once you delete an Team, there is no going back. All your data will be deleted in the background.",
"teams.empty": "Esta equipa ainda não tem aplicativos.",
"teams.leave": "Deixar a equipa",
"teams.leaveConfirmText": "Queres mesmo deixar esta equipa?",

30
backend/i18n/frontend_zh.json

@ -17,16 +17,16 @@
"apps.appsButtonCreate": "应用概览",
"apps.appsButtonCreateTeam": "Create Team",
"apps.appsButtonFallbackTitle": "应用概览",
"apps.archiveFailed": "Failed to archive app.",
"apps.archiveFailed": "Failed to archive App.",
"apps.create": "创建应用程序",
"apps.createBlankApp": "新应用程序",
"apps.createBlankAppDescription": "创建一个没有内容和Schemas的新空白应用程序。",
"apps.createFailed": "创建应用失败。请重新加载。",
"apps.createWithTemplate": "创建 {template} 示例",
"apps.delete": "Delete App",
"apps.deleteConfirmText": "Do you really want to delete this app?",
"apps.deleteConfirmText": "Do you really want to delete this App?",
"apps.deleteConfirmTitle": "I understand. Delete my App",
"apps.deleteWarning": "Once you delete an app, there is no going back. All your data will be deleted in the background.",
"apps.deleteWarning": "Once you delete an App, there is no going back. All your data will be deleted in the background.",
"apps.empty": "您还没有与任何应用协作",
"apps.generalSettings": "通用",
"apps.generalSettingsDangerZone": "通用",
@ -200,6 +200,7 @@
"common.aspectRatio": "纵横比",
"common.assets": "资源",
"common.assetScripts": "Asset Scripts",
"common.auth": "Authentication",
"common.back": "返回",
"common.backendError": "后端错误",
"common.backups": "备份",
@ -1038,9 +1039,30 @@
"start.login": "登录 Squidex",
"start.loginHint": "登录按钮将打开一个新的弹出窗口。一旦您登录成功,我们会将您重定向到 Squidex 管理门户。",
"start.madeBy": "自豪地制作",
"start.madeByCopyright": "Sebastian Stehle 和贡献者,2016-2021",
"start.madeByCopyright": "Sebastian Stehle 和贡献者,2016-2024",
"teams.archiveFailed": "Failed to archive Team.",
"teams.auth.authority": "Authority",
"teams.auth.authorityHint": "The URL to your authority server.",
"teams.auth.clientId": "Client ID",
"teams.auth.clientSecret": "Client Secret",
"teams.auth.displayName": "Display Name",
"teams.auth.displayNameHint": "The name for buttons and other UI elements.",
"teams.auth.domain": "Domain",
"teams.auth.domainHint": "The domain is used to identity your users. When they enter their email address and the domain matches to your settings they will redirected to your authentication server.",
"teams.auth.domainHintEmail": "Email Format",
"teams.auth.redirectUrl": "Redirect URL",
"teams.auth.redirectUrlHint": "You have to allow this URL in your authentication server.",
"teams.auth.reloaded": "Auth setting reloaded.",
"teams.auth.signoutRedirectUrl": "Signout Redirect URL",
"teams.auth.testLogin": "Test Login",
"teams.auth.use": "Custom OIDC Server",
"teams.auth.useHint": "Use single-sign-on (SSO) solution to connect Squidex to your infrastructure. Your employees and collegues can connect with their normal business account.",
"teams.create": "Create",
"teams.createFailed": "Failed to create team. Please reload.",
"teams.delete": "Delete Team",
"teams.deleteConfirmText": "Do you really want to delete this Team?",
"teams.deleteConfirmTitle": "I understand. Delete my Team",
"teams.deleteWarning": "Once you delete an Team, there is no going back. All your data will be deleted in the background.",
"teams.empty": "This team has no apps yet.",
"teams.leave": "Leave team",
"teams.leaveConfirmText": "Do you really want to leave this team?",

13
backend/i18n/source/backend_en.json

@ -37,7 +37,6 @@
"common.aspectHeight": "Aspect height",
"common.aspectWidth": "Aspect width",
"common.calculatedDefaultValue": "Calculated default value",
"common.clientd": "Client ID",
"common.clientId": "Client ID",
"common.clientSecret": "Client Secret",
"common.contentType": "Content type",
@ -111,6 +110,7 @@
"common.role": "Role",
"common.save": "Save",
"common.schemaId": "Schema ID",
"common.signoutRedirectUrl": "Signout Redirect URL",
"common.signup": "Signup",
"common.success": "Success",
"common.text": "Text",
@ -274,6 +274,9 @@
"jobs.ruleRunNamedSnapshot": "Replay Rule '{name}' from states.",
"jobs.ruleRunSnapshot": "Replay Rule from states",
"login.githubPrivateEmail": "Your email address is set to private in Github. Please set it to public to use Github login.",
"login.test.headline": "Successful Test",
"login.test.text": "Login Test was successful. You can save the settings now. It is recommended to make a final test with the actual login flow.",
"login.test.title": "Login Test",
"schemas.dateTimeCalculatedDefaultAndDefaultError": "Calculated default value and default value cannot be used together.",
"schemas.duplicateFieldName": "Field '{field}' has been added twice.",
"schemas.fieldCannotBeUIField": "Field cannot be an UI field.",
@ -309,7 +312,7 @@
"setup.headline": "Installation",
"setup.hint": "You see this screen because no user exists yet. After a user is created, you are not able to use this setup page again.",
"setup.madeBy": "Proudly made by",
"setup.madeByCopyright": "Sebastian Stehle and Contributors, 2016-2021",
"setup.madeByCopyright": "Sebastian Stehle and Contributors, 2016-2024",
"setup.ruleAppCreation.warningAdmins": "With your setup, only admins can create new apps. If you want to change this set <code>UI__ONLYADMINSCANCREATEAPPS=false</code> as environment variable.",
"setup.ruleAppCreation.warningAll": "With your setup, every user can create new apps. If you want to change this set <code>UI__ONLYADMINSCANCREATEAPPS=true</code> as environment variable.",
"setup.ruleFolder.warning": "You are using the <strong>folder asset store</strong> where all assets are stored in the file system. Please remember to include the asset folder into your backup strategy and map it to a volume, if you are using docker.",
@ -322,6 +325,8 @@
"setup.ruleUrl.failure": "You should access Squidex only over one canonical URL and configure this URL with the <code>URLS__BASEURL</code> environment variable. The current base URL <code>{actual}</code> does not match to the base url <code>{configured}</code>. This variable must point to the public URL under which your Squidex instance is available.",
"setup.ruleUrl.success": "Congratulations, the <code>URLS__BASEURL</code> environment variable is configured properly. This variable must point to the public URL under which your Squidex instance is available.",
"setup.title": "Installation",
"teams.appsAssigned": "Cannot delete team, when apps are assigned.",
"teams.domainInUse": "Domain is already used for another team.",
"users.accessDenied.text": "This operation is not allowed, your account might be locked.",
"users.accessDenied.title": "Access denied",
"users.consent.agree": "I agree!",
@ -343,6 +348,8 @@
"users.lockedOutTitle": "Account locked",
"users.lockYourselfError": "You cannot lock yourself.",
"users.login.askAdmin": "",
"users.login.custom": "Enter your E-Mail Address to login with your Company Account and single sign on (SSO).",
"users.login.emailBusinessPlaceholder": "Enter Business Email",
"users.login.emailPlaceholder": "Enter Email",
"users.login.error": "Email or password not correct",
"users.login.loginWith": "{action} with <strong>{provider}</strong>",
@ -355,6 +362,7 @@
"users.logout.headline": "Logged out!",
"users.logout.text": "!Please close this popup.",
"users.logout.title": "Logout",
"users.noCustomDomain": "No authentication server registered.",
"users.noEmailAddress": "We cannot get the email address from authentication provider.",
"users.profile.aboutHint": "Please share some information about your. It helps us to get an understanding about our users and to improve the product.",
"users.profile.aboutTitle": "About You",
@ -419,6 +427,7 @@
"validation.requiredBoth": "If {property1|lower} or {property2|lower} is used both must be defined.",
"validation.requiredValue": "Value must be defined.",
"validation.slug": "{property|upper} is not a valid slug.",
"validation.url": "{property|upper} is not a valid URL.",
"validation.valid": "{property|upper} is not a valid value.",
"workflows.overlap": "Multiple workflows cover all schemas.",
"workflows.publishedIsInitial": "Initial step cannot be published step.",

2
backend/i18n/source/backend_fr.json

@ -303,7 +303,7 @@
"setup.headline": "Installation",
"setup.hint": "Cet écran s'affiche car aucun utilisateur n'existe encore. Une fois qu'un utilisateur est créé, vous ne pouvez plus utiliser cette page de configuration.",
"setup.madeBy": "Fièrement fabriqué par",
"setup.madeByCopyright": "Sebastian Stehle et contributeurs, 2016-2021",
"setup.madeByCopyright": "Sebastian Stehle et contributeurs, 2016-2024",
"setup.ruleAppCreation.warningAdmins": "Avec votre configuration, seuls les administrateurs peuvent créer de nouvelles applications. Si vous souhaitez modifier cela, définissez <code>UI__ONLYADMINSCANCREATEAPPS=false</code> comme variable d'environnement.",
"setup.ruleAppCreation.warningAll": "Avec votre configuration, chaque utilisateur peut créer de nouvelles applications. Si vous souhaitez modifier cela, définissez <code>UI__ONLYADMINSCANCREATEAPPS=true</code> comme variable d'environnement.",
"setup.ruleFolder.warning": "Vous utilisez le <strong>magasin d'actifs de dossier</strong> où tous les actifs sont stockés dans le système de fichiers. N'oubliez pas d'inclure le dossier d'actifs dans votre stratégie de sauvegarde et de le mapper sur un volume, si vous utilisez docker.",

2
backend/i18n/source/backend_it.json

@ -273,7 +273,7 @@
"setup.headline": "Installazione",
"setup.hint": "Vedi questa schermata perché non esiste ancora alcun utente. Dopo aver creato un utente, non sarà più possibile visualizzare questa schermata.",
"setup.madeBy": "Orgogliosamente realizzato da",
"setup.madeByCopyright": "Sebastian Stehle e Collaboratori, 2016-2021",
"setup.madeByCopyright": "Sebastian Stehle e Collaboratori, 2016-2024",
"setup.ruleAppCreation.warningAdmins": "Con questa impostazione solamente gli amministratori possono creare nuove app. Se volessi cambiare questa impostazione utilizza <code>UI__ONLYADMINSCANCREATEAPPS=false</code> come variabile d'ambiente.",
"setup.ruleAppCreation.warningAll": "Con questa impostazione, ogni utente può creare nuove app. Se volessi cambiare questa impostazione utilizza <code>UI__ONLYADMINSCANCREATEAPPS=true</code> come variabile d'ambiente.",
"setup.ruleFolder.warning": "Stai usando <strong>la cartella delle risorse</strong> dove tutte le risorse sono salvate all'interno del file system. Ricordati per favore di includere la cartella delle risorse all'interno della gestione dei backup e mappalo in un volume, se stai usando Docker.",

2
backend/i18n/source/backend_zh.json

@ -274,7 +274,7 @@
"setup.headline": "安装",
"setup.hint": "您看到此屏幕是因为尚无用户存在。创建用户后,您将无法再次使用此屏幕。",
"setup.madeBy": "制作",
"setup.madeByCopyright": "Sebastian Stehle 和贡献者,2016-2021",
"setup.madeByCopyright": "Sebastian Stehle 和贡献者,2016-2024",
"setup.ruleAppCreation.warningAdmins": "通过你的设置,只有管理员可以创建新的应用程序。如果你想改变这个设置 <code>UI__ONLYADMINCANCREATEAPPS=false</code> 作为环境变量。",
"setup.ruleAppCreation.warningAll": "通过你的设置,每个用户都可以创建新的应用程序。如果你想改变这个设置 <code>UI__ONLYADMINCANCREATEAPPS=true</code> 作为环境变量。",
"setup.ruleFolder.warning": "您正在使用<strong>文件夹资源存储</strong>,其中所有资源都存储在文件系统中。请记住将资源文件夹包含在您的备份策略中并将其映射到卷, 如果您使用的是 Docker。",

30
backend/i18n/source/frontend_en.json

@ -17,16 +17,16 @@
"apps.appsButtonCreate": "Create App",
"apps.appsButtonCreateTeam": "Create Team",
"apps.appsButtonFallbackTitle": "Apps and Teams",
"apps.archiveFailed": "Failed to archive app.",
"apps.archiveFailed": "Failed to archive App.",
"apps.create": "Create App",
"apps.createBlankApp": "New App",
"apps.createBlankAppDescription": "Create a new blank app without content and schemas.",
"apps.createFailed": "Failed to create app. Please reload.",
"apps.createWithTemplate": "Create {template} Sample",
"apps.delete": "Delete App",
"apps.deleteConfirmText": "Do you really want to delete this app?",
"apps.deleteConfirmText": "Do you really want to delete this App?",
"apps.deleteConfirmTitle": "I understand. Delete my App",
"apps.deleteWarning": "Once you delete an app, there is no going back. All your data will be deleted in the background.",
"apps.deleteWarning": "Once you delete an App, there is no going back. All your data will be deleted in the background.",
"apps.empty": "You are not collaborating on any apps yet",
"apps.generalSettings": "General",
"apps.generalSettingsDangerZone": "Danger Zone",
@ -200,6 +200,7 @@
"common.aspectRatio": "AspectRatio",
"common.assets": "Assets",
"common.assetScripts": "Asset Scripts",
"common.auth": "Authentication",
"common.back": "Back",
"common.backendError": "Backend ERROR",
"common.backups": "Backups",
@ -1038,9 +1039,30 @@
"start.login": "Login to Squidex",
"start.loginHint": "The login button will open a new popup. Once you are logged in successful we will redirect you to the Squidex management portal.",
"start.madeBy": "Proudly made by",
"start.madeByCopyright": "Sebastian Stehle and Contributors, 2016-2021",
"start.madeByCopyright": "Sebastian Stehle and Contributors, 2016-2024",
"teams.archiveFailed": "Failed to archive Team.",
"teams.auth.authority": "Authority",
"teams.auth.authorityHint": "The URL to your authority server.",
"teams.auth.clientId": "Client ID",
"teams.auth.clientSecret": "Client Secret",
"teams.auth.displayName": "Display Name",
"teams.auth.displayNameHint": "The name for buttons and other UI elements.",
"teams.auth.domain": "Domain",
"teams.auth.domainHint": "The domain is used to identity your users. When they enter their email address and the domain matches to your settings they will redirected to your authentication server.",
"teams.auth.domainHintEmail": "Email Format",
"teams.auth.redirectUrl": "Redirect URL",
"teams.auth.redirectUrlHint": "You have to allow this URL in your authentication server.",
"teams.auth.reloaded": "Auth setting reloaded.",
"teams.auth.signoutRedirectUrl": "Signout Redirect URL",
"teams.auth.testLogin": "Test Login",
"teams.auth.use": "Custom OIDC Server",
"teams.auth.useHint": "Use single-sign-on (SSO) solution to connect Squidex to your infrastructure. Your employees and collegues can connect with their normal business account.",
"teams.create": "Create",
"teams.createFailed": "Failed to create team. Please reload.",
"teams.delete": "Delete Team",
"teams.deleteConfirmText": "Do you really want to delete this Team?",
"teams.deleteConfirmTitle": "I understand. Delete my Team",
"teams.deleteWarning": "Once you delete an Team, there is no going back. All your data will be deleted in the background.",
"teams.empty": "This team has no apps yet.",
"teams.leave": "Leave team",
"teams.leaveConfirmText": "Do you really want to leave this team?",

2
backend/i18n/source/frontend_fr.json

@ -1006,7 +1006,7 @@
"start.login": "Connectez-vous à Squidex",
"start.loginHint": "Le bouton de connexion ouvrira une nouvelle fenêtre contextuelle. Une fois que vous êtes connecté avec succès, nous vous redirigerons vers le portail de gestion Squidex.",
"start.madeBy": "Fièrement fabriqué par",
"start.madeByCopyright": "Sebastian Stehle et contributeurs, 2016-2021",
"start.madeByCopyright": "Sebastian Stehle et contributeurs, 2016-2024",
"teams.create": "Créer",
"teams.createFailed": "Échec de la création de l'équipe. Veuillez recharger.",
"teams.empty": "Cette équipe n'a pas encore d'applications.",

2
backend/i18n/source/frontend_zh.json

@ -817,7 +817,7 @@
"start.login": "登录 Squidex",
"start.loginHint": "登录按钮将打开一个新的弹出窗口。一旦您登录成功,我们会将您重定向到 Squidex 管理门户。",
"start.madeBy": "自豪地制作",
"start.madeByCopyright": "Sebastian Stehle 和贡献者,2016-2021",
"start.madeByCopyright": "Sebastian Stehle 和贡献者,2016-2024",
"tour.skip": "跳过游览",
"tour.stepAppNext": "继续",
"tour.stepAppText": "应用程序是您项目的存储库,例如(博客、网上商店或移动应用程序)。您可以为您的应用程序分配贡献者以协同工作。\n\n您可以在其中创建无限数量的应用程序Squidex 同时管理多个项目。",

6
backend/i18n/translator/Squidex.Translator/Processes/Helper.cs

@ -15,7 +15,7 @@ public static class Helper
[
[
"apps",
"team"
"teams"
],
[
"chatBot",
@ -134,7 +134,7 @@ public static class Helper
{
if (translations.Count > 0)
{
var prefixes = new HashSet<string>();
var prefixes = new SortedSet<string>();
foreach (var key in translations.ToList())
{
@ -176,7 +176,7 @@ public static class Helper
return translations;
}
private static bool HasInvalidPrefixes(HashSet<string> prefixes)
private static bool HasInvalidPrefixes(SortedSet<string> prefixes)
{
if (prefixes.Count <= 1)
{

2
backend/src/Migrations/Migrations.csproj

@ -6,7 +6,7 @@
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Meziantou.Analyzer" Version="2.0.146">
<PackageReference Include="Meziantou.Analyzer" Version="2.0.150">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

2
backend/src/Squidex.Domain.Apps.Core.Model/Squidex.Domain.Apps.Core.Model.csproj

@ -12,7 +12,7 @@
<DebugSymbols>True</DebugSymbols>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Meziantou.Analyzer" Version="2.0.146">
<PackageReference Include="Meziantou.Analyzer" Version="2.0.150">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

23
backend/src/Squidex.Domain.Apps.Core.Model/Teams/AuthScheme.cs

@ -0,0 +1,23 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
namespace Squidex.Domain.Apps.Core.Teams;
public sealed record AuthScheme
{
public string Domain { get; init; }
public string DisplayName { get; init; }
public string ClientId { get; init; }
public string ClientSecret { get; init; }
public string Authority { get; init; }
public string? SignoutRedirectUrl { get; init; }
}

15
backend/src/Squidex.Domain.Apps.Core.Model/Teams/Team.cs

@ -19,6 +19,10 @@ public record Team : Entity
public AssignedPlan? Plan { get; init; }
public AuthScheme? AuthScheme { get; init; }
public bool IsDeleted { get; init; }
[Pure]
public Team Rename(string name)
{
@ -43,6 +47,17 @@ public record Team : Entity
return this with { Plan = plan };
}
[Pure]
public Team ChangeAuthScheme(AuthScheme? authScheme)
{
if (Equals(authScheme, AuthScheme))
{
return this;
}
return this with { AuthScheme = authScheme };
}
[Pure]
public Team UpdateContributors<T>(T state, Func<T, Contributors, Contributors> update)
{

6
backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/Parser.cs

@ -5,8 +5,8 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Esprima;
using Esprima.Ast;
using Jint;
using Microsoft.Extensions.Caching.Memory;
namespace Squidex.Domain.Apps.Core.Scripting.Internal;
@ -22,7 +22,7 @@ internal sealed class Parser
this.cache = cache;
}
public Script Parse(string script)
public Prepared<Script> Parse(string script)
{
var cacheKey = $"{typeof(Parser)}_Script_{script}";
@ -30,7 +30,7 @@ internal sealed class Parser
{
entry.AbsoluteExpirationRelativeToNow = CacheDuration;
return new JavaScriptParser().ParseScript(script);
return Engine.PrepareScript(script);
})!;
}
}

4
backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptExecutionContext.cs

@ -21,7 +21,7 @@ public abstract class ScriptExecutionContext : ScriptVars
Engine = engine;
}
public abstract JsValue Evaluate(Script script);
public abstract JsValue Evaluate(Prepared<Script> script);
public abstract void Schedule(Func<IScheduler, CancellationToken, Task> action);
}
@ -67,7 +67,7 @@ public sealed class ScriptExecutionContext<T> : ScriptExecutionContext, ISchedul
tcs.TrySetResult(new CompletedValue { Value = value });
}
public override JsValue Evaluate(Script script)
public override JsValue Evaluate(Prepared<Script> script)
{
lock (Engine)
{

6
backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj

@ -18,10 +18,10 @@
<ProjectReference Include="..\Squidex.Shared\Squidex.Shared.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Fluid.Core" Version="2.7.0" />
<PackageReference Include="Fluid.Core" Version="2.9.0" />
<PackageReference Include="GeoJSON.Net" Version="1.2.19" />
<PackageReference Include="Jint" Version="3.0.1" />
<PackageReference Include="Meziantou.Analyzer" Version="2.0.146">
<PackageReference Include="Jint" Version="3.1.1" />
<PackageReference Include="Meziantou.Analyzer" Version="2.0.150">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

4
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Squidex.Domain.Apps.Entities.MongoDb.csproj

@ -19,11 +19,11 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Lucene.Net.QueryParser" Version="4.8.0-beta00016" />
<PackageReference Include="Meziantou.Analyzer" Version="2.0.146">
<PackageReference Include="Meziantou.Analyzer" Version="2.0.150">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="MongoDB.Driver" Version="2.24.0" />
<PackageReference Include="MongoDB.Driver" Version="2.25.0" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.ValueTuple" Version="4.5.0" />

11
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Teams/MongoTeamEntity.cs

@ -19,10 +19,18 @@ public sealed class MongoTeamEntity : MongoState<Team>
[BsonElement("_ui")]
public string[] IndexedUserIds { get; set; }
[BsonIgnoreIfDefault]
[BsonElement("_dl")]
public bool IndexedDeleted { get; set; }
[BsonIgnoreIfDefault]
[BsonElement("_ct")]
public Instant IndexedCreated { get; set; }
[BsonIgnoreIfDefault]
[BsonElement("_ad")]
public string? IndexedAuthDomain { get; set; }
public override void Prepare()
{
var users = new HashSet<string>
@ -32,6 +40,9 @@ public sealed class MongoTeamEntity : MongoState<Team>
users.AddRange(Document.Contributors.Keys);
IndexedAuthDomain = Document.AuthScheme?.Domain;
IndexedCreated = Document.Created;
IndexedDeleted = Document.IsDeleted;
IndexedUserIds = users.ToArray();
}
}

17
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Teams/MongoTeamRepository.cs

@ -42,7 +42,7 @@ public sealed class MongoTeamRepository : MongoSnapshotStoreBase<Team, MongoTeam
using (Telemetry.Activities.StartActivity("MongoTeamRepository/QueryAllAsync"))
{
var entities =
await Collection.Find(x => x.IndexedUserIds.Contains(contributorId))
await Collection.Find(x => x.IndexedUserIds.Contains(contributorId) && !x.IndexedDeleted)
.ToListAsync(ct);
return entities.Select(x => x.Document).ToList();
@ -55,7 +55,20 @@ public sealed class MongoTeamRepository : MongoSnapshotStoreBase<Team, MongoTeam
using (Telemetry.Activities.StartActivity("MongoTeamRepository/FindAsync"))
{
var entity =
await Collection.Find(x => x.DocumentId == id)
await Collection.Find(x => x.DocumentId == id && !x.IndexedDeleted)
.FirstOrDefaultAsync(ct);
return entity?.Document;
}
}
public async Task<Team?> FindByAuthDomainAsync(string authDomain,
CancellationToken ct = default)
{
using (Telemetry.Activities.StartActivity("MongoTeamRepository/FindByAuthDomainAsync"))
{
var entity =
await Collection.Find(x => x.IndexedAuthDomain == authDomain && !x.IndexedDeleted)
.FirstOrDefaultAsync(ct);
return entity?.Document;

20
backend/src/Squidex.Domain.Apps.Entities/AppProvider.cs

@ -106,6 +106,19 @@ public sealed class AppProvider : IAppProvider
return team;
}
public async Task<Team?> GetTeamByAuthDomainAsync(string authDomain,
CancellationToken ct = default)
{
var cacheKey = TeamCacheKey(authDomain);
var team = await GetOrCreate(cacheKey, () =>
{
return indexForTeams.GetTeamByAuthDomainAsync(authDomain, ct);
});
return team;
}
public async Task<Schema?> GetSchemaAsync(DomainId appId, string name, bool canCache = false,
CancellationToken ct = default)
{
@ -252,7 +265,12 @@ public sealed class AppProvider : IAppProvider
private static string TeamCacheKey(DomainId teamId)
{
return $"TEAMS_ID{teamId}";
return $"TEAMS_ID_{teamId}";
}
private static string TeamCacheKey(string authDomain)
{
return $"TEAMS_DOMAIN_{authDomain}";
}
private static string SchemaCacheKey(DomainId appId, DomainId id)

2
backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/Guards/GuardAppClients.cs

@ -59,7 +59,7 @@ public static class GuardAppClients
{
if (string.IsNullOrWhiteSpace(command.Id))
{
e(Not.Defined("Clientd"), nameof(command.Id));
e(Not.Defined("ClientId"), nameof(command.Id));
}
if (command.Role != null && !app.Roles.Contains(command.Role))

3
backend/src/Squidex.Domain.Apps.Entities/IAppProvider.cs

@ -22,6 +22,9 @@ public interface IAppProvider
Task<Team?> GetTeamAsync(DomainId teamId,
CancellationToken ct = default);
Task<Team?> GetTeamByAuthDomainAsync(string authDomain,
CancellationToken ct = default);
Task<List<Team>> GetUserTeamsAsync(string userId,
CancellationToken ct = default);

4
backend/src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj

@ -24,10 +24,10 @@
<ProjectReference Include="..\Squidex.Shared\Squidex.Shared.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="CsvHelper" Version="31.0.2" />
<PackageReference Include="CsvHelper" Version="32.0.2" />
<PackageReference Include="GraphQL" Version="7.8.0" />
<PackageReference Include="GraphQL.DataLoader" Version="7.8.0" />
<PackageReference Include="Meziantou.Analyzer" Version="2.0.146">
<PackageReference Include="Meziantou.Analyzer" Version="2.0.150">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

12
backend/src/Squidex.Domain.Apps.Entities/Teams/Commands/DeleteTeam.cs

@ -0,0 +1,12 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
namespace Squidex.Domain.Apps.Entities.Teams.Commands;
public sealed class DeleteTeam : TeamCommand
{
}

15
backend/src/Squidex.Domain.Apps.Entities/Teams/Commands/UpsertAuth.cs

@ -0,0 +1,15 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Core.Teams;
namespace Squidex.Domain.Apps.Entities.Teams.Commands;
public sealed class UpsertAuth : TeamCommand
{
public AuthScheme? Scheme { get; set; }
}

76
backend/src/Squidex.Domain.Apps.Entities/Teams/DomainObject/Guards/GuardTeam.cs

@ -59,4 +59,80 @@ public static class GuardTeam
}
});
}
public static Task CanUpsertAuth(UpsertAuth command, IAppProvider appProvider,
CancellationToken ct)
{
Guard.NotNull(command);
var scheme = command.Scheme;
if (scheme == null)
{
return Task.CompletedTask;
}
return Validate.It(async e =>
{
var prefix = nameof(command.Scheme);
if (string.IsNullOrWhiteSpace(scheme.Domain))
{
e(Not.Defined(nameof(scheme.Domain)), $"{prefix}.{nameof(scheme.Domain)}");
}
else
{
var existing = await appProvider.GetTeamByAuthDomainAsync(scheme.Domain, ct);
if (existing != null && existing.Id != command.TeamId)
{
e(T.Get("teams.domainInUse"));
}
}
if (string.IsNullOrWhiteSpace(scheme.DisplayName))
{
e(Not.Defined(nameof(scheme.DisplayName)), $"{prefix}.{nameof(scheme.DisplayName)}");
}
if (string.IsNullOrWhiteSpace(scheme.ClientId))
{
e(Not.Defined(nameof(scheme.ClientId)), $"{prefix}.{nameof(scheme.ClientId)}");
}
if (string.IsNullOrWhiteSpace(scheme.ClientSecret))
{
e(Not.Defined(nameof(scheme.ClientSecret)), $"{prefix}.{nameof(scheme.ClientSecret)}");
}
if (string.IsNullOrWhiteSpace(scheme.Authority))
{
e(Not.Defined(nameof(scheme.Authority)), $"{prefix}.{nameof(scheme.Authority)}");
}
else if (!Uri.IsWellFormedUriString(scheme.Authority, UriKind.Absolute))
{
e(Not.ValidUrl(nameof(scheme.Authority)), $"{prefix}.{nameof(scheme.Authority)}");
}
if (!string.IsNullOrWhiteSpace(scheme.SignoutRedirectUrl) &&
!Uri.IsWellFormedUriString(scheme.SignoutRedirectUrl, UriKind.Absolute))
{
e(Not.ValidUrl(nameof(scheme.SignoutRedirectUrl)), $"{prefix}.{nameof(scheme.SignoutRedirectUrl)}");
}
});
}
public static Task CanDelete(DeleteTeam command, IAppProvider appProvider,
CancellationToken ct)
{
return Validate.It(async e =>
{
var assignedApps = await appProvider.GetTeamAppsAsync(command.TeamId, ct);
if (assignedApps.Count != 0)
{
e(T.Get("teams.appsAssigned"));
}
});
}
}

9
backend/src/Squidex.Domain.Apps.Entities/Teams/DomainObject/TeamDomainObject.State.cs

@ -8,6 +8,7 @@
using Squidex.Domain.Apps.Core;
using Squidex.Domain.Apps.Core.Teams;
using Squidex.Domain.Apps.Events;
using Squidex.Domain.Apps.Events.Apps;
using Squidex.Domain.Apps.Events.Teams;
using Squidex.Infrastructure.EventSourcing;
@ -44,6 +45,14 @@ public partial class TeamDomainObject
case TeamContributorRemoved e:
newSnapshot = snapshot.UpdateContributors(e, (e, c) => c.Remove(e.ContributorId));
break;
case TeamAuthChanged e:
newSnapshot = snapshot.ChangeAuthScheme(e.Scheme);
break;
case TeamDeleted:
newSnapshot = snapshot with { Plan = null, IsDeleted = true };
break;
}
if (ReferenceEquals(newSnapshot, snapshot))

35
backend/src/Squidex.Domain.Apps.Entities/Teams/DomainObject/TeamDomainObject.cs

@ -105,6 +105,26 @@ public partial class TeamDomainObject : DomainObject<Team>
return Snapshot;
}, ct);
case UpsertAuth upsertauth:
return ApplyReturnAsync(upsertauth, async (c, ct) =>
{
await GuardTeam.CanUpsertAuth(c, AppProvider, ct);
UpsertAuth(c);
return Snapshot;
}, ct);
case DeleteTeam delete:
return ApplyAsync(delete, async (c, ct) =>
{
await GuardTeam.CanDelete(c, AppProvider, ct);
await BillingManager.UnsubscribeAsync(c.Actor.Identifier, Snapshot, default);
DeleteTeam(c);
}, ct);
case ChangePlan changePlan:
return ApplyReturnAsync(changePlan, async (c, ct) =>
{
@ -179,6 +199,11 @@ public partial class TeamDomainObject : DomainObject<Team>
Raise(command, new TeamUpdated());
}
private void UpsertAuth(UpsertAuth command)
{
Raise(command, new TeamAuthChanged());
}
private void AssignContributor(AssignContributor command, bool isAdded)
{
Raise(command, new TeamContributorAssigned { IsAdded = isAdded });
@ -189,6 +214,11 @@ public partial class TeamDomainObject : DomainObject<Team>
Raise(command, new TeamContributorRemoved());
}
private void DeleteTeam(DeleteTeam command)
{
Raise(command, new TeamDeleted());
}
private void Raise<T, TEvent>(T command, TEvent @event, DomainId? id = null) where T : class where TEvent : TeamEvent
{
SimpleMapper.Map(command, @event);
@ -198,6 +228,11 @@ public partial class TeamDomainObject : DomainObject<Team>
RaiseEvent(Envelope.Create(@event));
}
private IAppProvider AppProvider
{
get => serviceProvider.GetRequiredService<IAppProvider>();
}
private IBillingPlans BillingPlans
{
get => serviceProvider.GetRequiredService<IBillingPlans>();

3
backend/src/Squidex.Domain.Apps.Entities/Teams/Indexes/ITeamsIndex.cs

@ -15,6 +15,9 @@ public interface ITeamsIndex
Task<Team?> GetTeamAsync(DomainId id,
CancellationToken ct = default);
Task<Team?> GetTeamByAuthDomainAsync(string authDomain,
CancellationToken ct = default);
Task<List<Team>> GetTeamsAsync(string userId,
CancellationToken ct = default);
}

17
backend/src/Squidex.Domain.Apps.Entities/Teams/Indexes/TeamsIndex.cs

@ -33,6 +33,19 @@ public sealed class TeamsIndex : ITeamsIndex
}
}
public async Task<Team?> GetTeamByAuthDomainAsync(string authDomain,
CancellationToken ct = default)
{
using (var activity = Telemetry.Activities.StartActivity("TeamsIndex/GetTeamByAuthDomainAsync"))
{
activity?.SetTag("authDomain", authDomain);
var team = await teamRepository.FindByAuthDomainAsync(authDomain, ct);
return IsValid(team) ? team : null;
}
}
public async Task<List<Team>> GetTeamsAsync(string userId,
CancellationToken ct = default)
{
@ -46,8 +59,8 @@ public sealed class TeamsIndex : ITeamsIndex
}
}
private static bool IsValid(Team? rule)
private static bool IsValid(Team? team)
{
return rule is { Version: > EtagVersion.Empty };
return team is { Version: > EtagVersion.Empty, IsDeleted: false };
}
}

3
backend/src/Squidex.Domain.Apps.Entities/Teams/Repositories/ITeamRepository.cs

@ -17,4 +17,7 @@ public interface ITeamRepository
Task<Team?> FindAsync(DomainId id,
CancellationToken ct = default);
Task<Team?> FindByAuthDomainAsync(string authDomain,
CancellationToken ct = default);
}

2
backend/src/Squidex.Domain.Apps.Events/Squidex.Domain.Apps.Events.csproj

@ -14,7 +14,7 @@
<ProjectReference Include="..\Squidex.Infrastructure\Squidex.Infrastructure.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Meziantou.Analyzer" Version="2.0.146">
<PackageReference Include="Meziantou.Analyzer" Version="2.0.150">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

15
backend/src/Squidex.Domain.Apps.Events/Teams/TeamAuthChanged.cs

@ -0,0 +1,15 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Core.Teams;
namespace Squidex.Domain.Apps.Events.Teams;
public sealed class TeamAuthChanged : TeamEvent
{
public AuthScheme? Scheme { get; set; }
}

12
backend/src/Squidex.Domain.Apps.Events/Teams/TeamDeleted.cs

@ -0,0 +1,12 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
namespace Squidex.Domain.Apps.Events.Teams;
public sealed class TeamDeleted : TeamEvent
{
}

4
backend/src/Squidex.Domain.Users.MongoDb/Squidex.Domain.Users.MongoDb.csproj

@ -19,12 +19,12 @@
<ProjectReference Include="..\Squidex.Shared\Squidex.Shared.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Meziantou.Analyzer" Version="2.0.146">
<PackageReference Include="Meziantou.Analyzer" Version="2.0.150">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
<PackageReference Include="MongoDB.Driver" Version="2.24.0" />
<PackageReference Include="MongoDB.Driver" Version="2.25.0" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.Security.Principal.Windows" Version="5.0.0" />

8
backend/src/Squidex.Domain.Users/Squidex.Domain.Users.csproj

@ -17,14 +17,14 @@
<ProjectReference Include="..\Squidex.Shared\Squidex.Shared.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="IdentityModel" Version="6.2.0" />
<PackageReference Include="Meziantou.Analyzer" Version="2.0.146">
<PackageReference Include="IdentityModel" Version="7.0.0" />
<PackageReference Include="Meziantou.Analyzer" Version="2.0.150">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Identity.Stores" Version="8.0.3" />
<PackageReference Include="Microsoft.Extensions.Identity.Stores" Version="8.0.4" />
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
<PackageReference Include="OpenIddict.AspNetCore" Version="5.3.0" />
<PackageReference Include="OpenIddict.AspNetCore" Version="5.5.0" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="SharpPwned.NET" Version="2.0.1" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />

4
backend/src/Squidex.Infrastructure.GetEventStore/Squidex.Infrastructure.GetEventStore.csproj

@ -14,8 +14,8 @@
<PackageReference Include="EventStore.Client.Grpc.PersistentSubscriptions" Version="23.2.1" />
<PackageReference Include="EventStore.Client.Grpc.ProjectionManagement" Version="23.2.1" />
<PackageReference Include="EventStore.Client.Grpc.Streams" Version="23.2.1" />
<PackageReference Include="Grpc.Net.Client" Version="2.61.0" />
<PackageReference Include="Meziantou.Analyzer" Version="2.0.146">
<PackageReference Include="Grpc.Net.Client" Version="2.62.0" />
<PackageReference Include="Meziantou.Analyzer" Version="2.0.150">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

6
backend/src/Squidex.Infrastructure.MongoDb/Squidex.Infrastructure.MongoDb.csproj

@ -14,12 +14,12 @@
<ProjectReference Include="..\Squidex.Infrastructure\Squidex.Infrastructure.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Meziantou.Analyzer" Version="2.0.146">
<PackageReference Include="Meziantou.Analyzer" Version="2.0.150">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="MongoDB.Driver" Version="2.24.0" />
<PackageReference Include="MongoDB.Driver.GridFS" Version="2.24.0" />
<PackageReference Include="MongoDB.Driver" Version="2.25.0" />
<PackageReference Include="MongoDB.Driver.GridFS" Version="2.25.0" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.ValueTuple" Version="4.5.0" />

4
backend/src/Squidex.Infrastructure/Queries/OData/PropertyPathVisitor.cs

@ -37,7 +37,7 @@ public sealed class PropertyPathVisitor : QueryNodeVisitor<ImmutableList<string>
}
else
{
return ImmutableList.Create(UnescapeEdmField(nodeIn.Property));
return [UnescapeEdmField(nodeIn.Property)];
}
}
@ -49,7 +49,7 @@ public sealed class PropertyPathVisitor : QueryNodeVisitor<ImmutableList<string>
}
else
{
return ImmutableList.Create(UnescapeEdmField(nodeIn.Property));
return [UnescapeEdmField(nodeIn.Property)];
}
}

10
backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj

@ -11,18 +11,18 @@
<DebugSymbols>True</DebugSymbols>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MailKit" Version="4.4.0" />
<PackageReference Include="MailKit" Version="4.5.0" />
<PackageReference Include="McMaster.NETCore.Plugins" Version="1.4.0" />
<PackageReference Include="Meziantou.Analyzer" Version="2.0.146">
<PackageReference Include="Meziantou.Analyzer" Version="2.0.150">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions" Version="8.0.3" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions" Version="8.0.4" />
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.0" />
<PackageReference Include="Microsoft.OData.Core" Version="7.20.0" />
<PackageReference Include="Microsoft.OData.Core" Version="7.21.1" />
<PackageReference Include="NodaTime" Version="3.1.11" />
<PackageReference Include="OpenTelemetry.Api" Version="1.7.0" />
<PackageReference Include="OpenTelemetry.Api" Version="1.8.1" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="Squidex.Assets" Version="6.6.4" />
<PackageReference Include="Squidex.Caching" Version="6.6.4" />

10
backend/src/Squidex.Infrastructure/Validation/Not.cs

@ -1,4 +1,4 @@
// ==========================================================================
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
@ -44,6 +44,14 @@ public static class Not
return T.Get("validation.slug", new { property });
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static string ValidUrl(string propertyName)
{
var property = T.Get($"common.{propertyName.ToCamelCase()}", propertyName);
return T.Get("validation.url", new { property });
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static string ValidJavascriptName(string propertyName)
{

6
backend/src/Squidex.Shared/PermissionIds.cs

@ -46,6 +46,7 @@ public static class PermissionIds
// Team General
public const string TeamAdmin = "squidex.teams.{team}.*";
public const string TeamDelete = "squidex.teams.{team}.delete";
public const string TeamUpdate = "squidex.teams.{team}.update";
// Team Contributors
@ -59,6 +60,11 @@ public static class PermissionIds
public const string TeamPlansRead = "squidex.teams.{team}.plans.read";
public const string TeamPlansChange = "squidex.teams.{team}.plans.change";
// Team Auth
public const string TeamAuth = "squidex.teams.{team}.auth";
public const string TeamAuthRead = "squidex.teams.{team}.auth.read";
public const string TeamAuthChange = "squidex.teams.{team}.auth.change";
// Team Usage
public const string TeamUsage = "squidex.teams.{team}.usage";

2
backend/src/Squidex.Shared/Squidex.Shared.csproj

@ -10,7 +10,7 @@
<DebugSymbols>True</DebugSymbols>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Meziantou.Analyzer" Version="2.0.146">
<PackageReference Include="Meziantou.Analyzer" Version="2.0.150">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

38
backend/src/Squidex.Shared/Texts.fr.resx

@ -196,9 +196,6 @@
<data name="common.calculatedDefaultValue" xml:space="preserve">
<value>Valeur par défaut calculée</value>
</data>
<data name="common.clientd" xml:space="preserve">
<value>identité du client</value>
</data>
<data name="common.clientId" xml:space="preserve">
<value>identité du client</value>
</data>
@ -226,9 +223,6 @@
<data name="common.documentation" xml:space="preserve">
<value>Documentation</value>
</data>
<data name="common.editInNewTab" xml:space="preserve">
<value>Open in new tab</value>
</data>
<data name="common.editor" xml:space="preserve">
<value>Éditeur</value>
</data>
@ -421,6 +415,9 @@
<data name="common.schemaId" xml:space="preserve">
<value>ID de schéma</value>
</data>
<data name="common.signoutRedirectUrl" xml:space="preserve">
<value>Signout Redirect URL</value>
</data>
<data name="common.signup" xml:space="preserve">
<value>S'inscrire</value>
</data>
@ -910,6 +907,15 @@
<data name="login.githubPrivateEmail" xml:space="preserve">
<value>Votre adresse e-mail est définie sur privé dans Github. Veuillez le définir sur public pour utiliser la connexion Github.</value>
</data>
<data name="login.test.headline" xml:space="preserve">
<value>Successful Test</value>
</data>
<data name="login.test.text" xml:space="preserve">
<value>Login Test was successful. You can save the settings now. It is recommended to make a final test with the actual login flow.</value>
</data>
<data name="login.test.title" xml:space="preserve">
<value>Login Test</value>
</data>
<data name="schemas.dateTimeCalculatedDefaultAndDefaultError" xml:space="preserve">
<value>La valeur par défaut calculée et la valeur par défaut ne peuvent pas être utilisées ensemble.</value>
</data>
@ -1016,7 +1022,7 @@
<value>Fièrement fabriqué par</value>
</data>
<data name="setup.madeByCopyright" xml:space="preserve">
<value>Sebastian Stehle et contributeurs, 2016-2021</value>
<value>Sebastian Stehle et contributeurs, 2016-2024</value>
</data>
<data name="setup.ruleAppCreation.warningAdmins" xml:space="preserve">
<value>Avec votre configuration, seuls les administrateurs peuvent créer de nouvelles applications. Si vous souhaitez modifier cela, définissez &lt;code&gt;UI__ONLYADMINSCANCREATEAPPS=false&lt;/code&gt; comme variable d'environnement.</value>
@ -1054,6 +1060,12 @@
<data name="setup.title" xml:space="preserve">
<value>Installation</value>
</data>
<data name="teams.appsAssigned" xml:space="preserve">
<value>Cannot delete team, when apps are assigned.</value>
</data>
<data name="teams.domainInUse" xml:space="preserve">
<value>Domain is already used for another team.</value>
</data>
<data name="users.accessDenied.text" xml:space="preserve">
<value>Cette opération n'est pas autorisée, votre compte pourrait être bloqué.</value>
</data>
@ -1117,6 +1129,12 @@
<data name="users.login.askAdmin" xml:space="preserve">
<value />
</data>
<data name="users.login.custom" xml:space="preserve">
<value>Enter your E-Mail Address to login with your Company Account and single sign on (SSO).</value>
</data>
<data name="users.login.emailBusinessPlaceholder" xml:space="preserve">
<value>Enter Business Email</value>
</data>
<data name="users.login.emailPlaceholder" xml:space="preserve">
<value>Entrez l'e-mail</value>
</data>
@ -1153,6 +1171,9 @@
<data name="users.logout.title" xml:space="preserve">
<value>Se déconnecter</value>
</data>
<data name="users.noCustomDomain" xml:space="preserve">
<value>No authentication server registered.</value>
</data>
<data name="users.noEmailAddress" xml:space="preserve">
<value>Nous ne pouvons pas obtenir l'adresse e-mail du fournisseur d'authentification.</value>
</data>
@ -1345,6 +1366,9 @@
<data name="validation.slug" xml:space="preserve">
<value>{property|upper} n'est pas un slug valide.</value>
</data>
<data name="validation.url" xml:space="preserve">
<value>{property|upper} is not a valid URL.</value>
</data>
<data name="validation.valid" xml:space="preserve">
<value>{property|upper} n'est pas une valeur valide.</value>
</data>

38
backend/src/Squidex.Shared/Texts.it.resx

@ -196,9 +196,6 @@
<data name="common.calculatedDefaultValue" xml:space="preserve">
<value>Valore predefinito calcolato</value>
</data>
<data name="common.clientd" xml:space="preserve">
<value>ID Client</value>
</data>
<data name="common.clientId" xml:space="preserve">
<value>ID Client</value>
</data>
@ -226,9 +223,6 @@
<data name="common.documentation" xml:space="preserve">
<value>Documentation</value>
</data>
<data name="common.editInNewTab" xml:space="preserve">
<value>Open in new tab</value>
</data>
<data name="common.editor" xml:space="preserve">
<value>Redattore</value>
</data>
@ -421,6 +415,9 @@
<data name="common.schemaId" xml:space="preserve">
<value>ID Schema</value>
</data>
<data name="common.signoutRedirectUrl" xml:space="preserve">
<value>Signout Redirect URL</value>
</data>
<data name="common.signup" xml:space="preserve">
<value>Iscriviti</value>
</data>
@ -910,6 +907,15 @@
<data name="login.githubPrivateEmail" xml:space="preserve">
<value>Il tuo indirizzo email è impostato su privato in Github. Impostalo come pubblico per poter utilizzare il login Github.</value>
</data>
<data name="login.test.headline" xml:space="preserve">
<value>Successful Test</value>
</data>
<data name="login.test.text" xml:space="preserve">
<value>Login Test was successful. You can save the settings now. It is recommended to make a final test with the actual login flow.</value>
</data>
<data name="login.test.title" xml:space="preserve">
<value>Login Test</value>
</data>
<data name="schemas.dateTimeCalculatedDefaultAndDefaultError" xml:space="preserve">
<value>Il valore predefinito calcolato e il valore predefinito non possono essere utilizzati insieme.</value>
</data>
@ -1016,7 +1022,7 @@
<value>Orgogliosamente realizzato da</value>
</data>
<data name="setup.madeByCopyright" xml:space="preserve">
<value>Sebastian Stehle e Collaboratori, 2016-2021</value>
<value>Sebastian Stehle e Collaboratori, 2016-2024</value>
</data>
<data name="setup.ruleAppCreation.warningAdmins" xml:space="preserve">
<value>Con questa impostazione solamente gli amministratori possono creare nuove app. Se volessi cambiare questa impostazione utilizza &lt;code&gt;UI__ONLYADMINSCANCREATEAPPS=false&lt;/code&gt; come variabile d'ambiente.</value>
@ -1054,6 +1060,12 @@
<data name="setup.title" xml:space="preserve">
<value>Installazione</value>
</data>
<data name="teams.appsAssigned" xml:space="preserve">
<value>Cannot delete team, when apps are assigned.</value>
</data>
<data name="teams.domainInUse" xml:space="preserve">
<value>Domain is already used for another team.</value>
</data>
<data name="users.accessDenied.text" xml:space="preserve">
<value>Questa operazione non è consentita, il tuo account potrebbe essere bloccato.</value>
</data>
@ -1117,6 +1129,12 @@
<data name="users.login.askAdmin" xml:space="preserve">
<value />
</data>
<data name="users.login.custom" xml:space="preserve">
<value>Enter your E-Mail Address to login with your Company Account and single sign on (SSO).</value>
</data>
<data name="users.login.emailBusinessPlaceholder" xml:space="preserve">
<value>Enter Business Email</value>
</data>
<data name="users.login.emailPlaceholder" xml:space="preserve">
<value>Inserisci l'email</value>
</data>
@ -1153,6 +1171,9 @@
<data name="users.logout.title" xml:space="preserve">
<value>Esci</value>
</data>
<data name="users.noCustomDomain" xml:space="preserve">
<value>No authentication server registered.</value>
</data>
<data name="users.noEmailAddress" xml:space="preserve">
<value>We cannot get the email address from authentication provider.</value>
</data>
@ -1345,6 +1366,9 @@
<data name="validation.slug" xml:space="preserve">
<value>{property|upper} non è uno slug valido.</value>
</data>
<data name="validation.url" xml:space="preserve">
<value>{property|upper} is not a valid URL.</value>
</data>
<data name="validation.valid" xml:space="preserve">
<value>{property|upper} non è un valore valido.</value>
</data>

38
backend/src/Squidex.Shared/Texts.nl.resx

@ -196,9 +196,6 @@
<data name="common.calculatedDefaultValue" xml:space="preserve">
<value>Berekende standaardwaarde</value>
</data>
<data name="common.clientd" xml:space="preserve">
<value>Client-ID</value>
</data>
<data name="common.clientId" xml:space="preserve">
<value>Client-ID</value>
</data>
@ -226,9 +223,6 @@
<data name="common.documentation" xml:space="preserve">
<value>Documentatie</value>
</data>
<data name="common.editInNewTab" xml:space="preserve">
<value>Open in new tab</value>
</data>
<data name="common.editor" xml:space="preserve">
<value>Editor</value>
</data>
@ -421,6 +415,9 @@
<data name="common.schemaId" xml:space="preserve">
<value>Schema-ID</value>
</data>
<data name="common.signoutRedirectUrl" xml:space="preserve">
<value>Signout Redirect URL</value>
</data>
<data name="common.signup" xml:space="preserve">
<value>Aanmelden</value>
</data>
@ -910,6 +907,15 @@
<data name="login.githubPrivateEmail" xml:space="preserve">
<value>Jouw e-mailadres is ingesteld op privé in Github. Stel het in op openbaar om Github-login te gebruiken.</value>
</data>
<data name="login.test.headline" xml:space="preserve">
<value>Successful Test</value>
</data>
<data name="login.test.text" xml:space="preserve">
<value>Login Test was successful. You can save the settings now. It is recommended to make a final test with the actual login flow.</value>
</data>
<data name="login.test.title" xml:space="preserve">
<value>Login Test</value>
</data>
<data name="schemas.dateTimeCalculatedDefaultAndDefaultError" xml:space="preserve">
<value>Berekende standaardwaarde en standaardwaarde kunnen niet samen worden gebruikt.</value>
</data>
@ -1016,7 +1022,7 @@
<value>Proudly made by</value>
</data>
<data name="setup.madeByCopyright" xml:space="preserve">
<value>Sebastian Stehle and Contributors, 2016-2021</value>
<value>Sebastian Stehle and Contributors, 2016-2024</value>
</data>
<data name="setup.ruleAppCreation.warningAdmins" xml:space="preserve">
<value>With your setup, only admins can create new apps. If you want to change this set &lt;code&gt;UI__ONLYADMINSCANCREATEAPPS=false&lt;/code&gt; as environment variable.</value>
@ -1054,6 +1060,12 @@
<data name="setup.title" xml:space="preserve">
<value>Installation</value>
</data>
<data name="teams.appsAssigned" xml:space="preserve">
<value>Cannot delete team, when apps are assigned.</value>
</data>
<data name="teams.domainInUse" xml:space="preserve">
<value>Domain is already used for another team.</value>
</data>
<data name="users.accessDenied.text" xml:space="preserve">
<value>Deze bewerking is niet toegestaan, je account is mogelijk vergrendeld.</value>
</data>
@ -1117,6 +1129,12 @@
<data name="users.login.askAdmin" xml:space="preserve">
<value />
</data>
<data name="users.login.custom" xml:space="preserve">
<value>Enter your E-Mail Address to login with your Company Account and single sign on (SSO).</value>
</data>
<data name="users.login.emailBusinessPlaceholder" xml:space="preserve">
<value>Enter Business Email</value>
</data>
<data name="users.login.emailPlaceholder" xml:space="preserve">
<value>E-mailadres invoeren</value>
</data>
@ -1153,6 +1171,9 @@
<data name="users.logout.title" xml:space="preserve">
<value>Uitloggen</value>
</data>
<data name="users.noCustomDomain" xml:space="preserve">
<value>No authentication server registered.</value>
</data>
<data name="users.noEmailAddress" xml:space="preserve">
<value>We cannot get the email address from authentication provider.</value>
</data>
@ -1345,6 +1366,9 @@
<data name="validation.slug" xml:space="preserve">
<value>{property|upper} is geen geldige slug.</value>
</data>
<data name="validation.url" xml:space="preserve">
<value>{property|upper} is not a valid URL.</value>
</data>
<data name="validation.valid" xml:space="preserve">
<value>{property|upper} is geen geldige waarde.</value>
</data>

36
backend/src/Squidex.Shared/Texts.pt.resx

@ -196,9 +196,6 @@
<data name="common.calculatedDefaultValue" xml:space="preserve">
<value>Calculado o valor por defeito</value>
</data>
<data name="common.clientd" xml:space="preserve">
<value>ID do cliente</value>
</data>
<data name="common.clientId" xml:space="preserve">
<value>ID do cliente</value>
</data>
@ -226,9 +223,6 @@
<data name="common.documentation" xml:space="preserve">
<value>Documentação</value>
</data>
<data name="common.editInNewTab" xml:space="preserve">
<value>Open in new tab</value>
</data>
<data name="common.editor" xml:space="preserve">
<value>Editor</value>
</data>
@ -421,6 +415,9 @@
<data name="common.schemaId" xml:space="preserve">
<value>ID Esquema</value>
</data>
<data name="common.signoutRedirectUrl" xml:space="preserve">
<value>Signout Redirect URL</value>
</data>
<data name="common.signup" xml:space="preserve">
<value>Registar</value>
</data>
@ -910,6 +907,15 @@
<data name="login.githubPrivateEmail" xml:space="preserve">
<value>O seu Email é privado no Github. Altere para publico no Github e tente novamente.</value>
</data>
<data name="login.test.headline" xml:space="preserve">
<value>Successful Test</value>
</data>
<data name="login.test.text" xml:space="preserve">
<value>Login Test was successful. You can save the settings now. It is recommended to make a final test with the actual login flow.</value>
</data>
<data name="login.test.title" xml:space="preserve">
<value>Login Test</value>
</data>
<data name="schemas.dateTimeCalculatedDefaultAndDefaultError" xml:space="preserve">
<value>Valor por defeito calculado e valor por defeito não podem ser usado em conjunto.</value>
</data>
@ -1054,6 +1060,12 @@
<data name="setup.title" xml:space="preserve">
<value>Instalação</value>
</data>
<data name="teams.appsAssigned" xml:space="preserve">
<value>Cannot delete team, when apps are assigned.</value>
</data>
<data name="teams.domainInUse" xml:space="preserve">
<value>Domain is already used for another team.</value>
</data>
<data name="users.accessDenied.text" xml:space="preserve">
<value>Operação não permitida, a sua conta pode estár bloqueada.</value>
</data>
@ -1117,6 +1129,12 @@
<data name="users.login.askAdmin" xml:space="preserve">
<value />
</data>
<data name="users.login.custom" xml:space="preserve">
<value>Enter your E-Mail Address to login with your Company Account and single sign on (SSO).</value>
</data>
<data name="users.login.emailBusinessPlaceholder" xml:space="preserve">
<value>Enter Business Email</value>
</data>
<data name="users.login.emailPlaceholder" xml:space="preserve">
<value>Introduzir Email</value>
</data>
@ -1153,6 +1171,9 @@
<data name="users.logout.title" xml:space="preserve">
<value>Sair</value>
</data>
<data name="users.noCustomDomain" xml:space="preserve">
<value>No authentication server registered.</value>
</data>
<data name="users.noEmailAddress" xml:space="preserve">
<value>Não podemos obter o endereço de e-mail do provedor de autenticação.</value>
</data>
@ -1345,6 +1366,9 @@
<data name="validation.slug" xml:space="preserve">
<value>{property|upper} não é um slug válido.</value>
</data>
<data name="validation.url" xml:space="preserve">
<value>{property|upper} is not a valid URL.</value>
</data>
<data name="validation.valid" xml:space="preserve">
<value>{property|upper} não é um valor válido.</value>
</data>

38
backend/src/Squidex.Shared/Texts.resx

@ -196,9 +196,6 @@
<data name="common.calculatedDefaultValue" xml:space="preserve">
<value>Calculated default value</value>
</data>
<data name="common.clientd" xml:space="preserve">
<value>Client ID</value>
</data>
<data name="common.clientId" xml:space="preserve">
<value>Client ID</value>
</data>
@ -226,9 +223,6 @@
<data name="common.documentation" xml:space="preserve">
<value>Documentation</value>
</data>
<data name="common.editInNewTab" xml:space="preserve">
<value>Open in new tab</value>
</data>
<data name="common.editor" xml:space="preserve">
<value>Editor</value>
</data>
@ -421,6 +415,9 @@
<data name="common.schemaId" xml:space="preserve">
<value>Schema ID</value>
</data>
<data name="common.signoutRedirectUrl" xml:space="preserve">
<value>Signout Redirect URL</value>
</data>
<data name="common.signup" xml:space="preserve">
<value>Signup</value>
</data>
@ -910,6 +907,15 @@
<data name="login.githubPrivateEmail" xml:space="preserve">
<value>Your email address is set to private in Github. Please set it to public to use Github login.</value>
</data>
<data name="login.test.headline" xml:space="preserve">
<value>Successful Test</value>
</data>
<data name="login.test.text" xml:space="preserve">
<value>Login Test was successful. You can save the settings now. It is recommended to make a final test with the actual login flow.</value>
</data>
<data name="login.test.title" xml:space="preserve">
<value>Login Test</value>
</data>
<data name="schemas.dateTimeCalculatedDefaultAndDefaultError" xml:space="preserve">
<value>Calculated default value and default value cannot be used together.</value>
</data>
@ -1016,7 +1022,7 @@
<value>Proudly made by</value>
</data>
<data name="setup.madeByCopyright" xml:space="preserve">
<value>Sebastian Stehle and Contributors, 2016-2021</value>
<value>Sebastian Stehle and Contributors, 2016-2024</value>
</data>
<data name="setup.ruleAppCreation.warningAdmins" xml:space="preserve">
<value>With your setup, only admins can create new apps. If you want to change this set &lt;code&gt;UI__ONLYADMINSCANCREATEAPPS=false&lt;/code&gt; as environment variable.</value>
@ -1054,6 +1060,12 @@
<data name="setup.title" xml:space="preserve">
<value>Installation</value>
</data>
<data name="teams.appsAssigned" xml:space="preserve">
<value>Cannot delete team, when apps are assigned.</value>
</data>
<data name="teams.domainInUse" xml:space="preserve">
<value>Domain is already used for another team.</value>
</data>
<data name="users.accessDenied.text" xml:space="preserve">
<value>This operation is not allowed, your account might be locked.</value>
</data>
@ -1117,6 +1129,12 @@
<data name="users.login.askAdmin" xml:space="preserve">
<value />
</data>
<data name="users.login.custom" xml:space="preserve">
<value>Enter your E-Mail Address to login with your Company Account and single sign on (SSO).</value>
</data>
<data name="users.login.emailBusinessPlaceholder" xml:space="preserve">
<value>Enter Business Email</value>
</data>
<data name="users.login.emailPlaceholder" xml:space="preserve">
<value>Enter Email</value>
</data>
@ -1153,6 +1171,9 @@
<data name="users.logout.title" xml:space="preserve">
<value>Logout</value>
</data>
<data name="users.noCustomDomain" xml:space="preserve">
<value>No authentication server registered.</value>
</data>
<data name="users.noEmailAddress" xml:space="preserve">
<value>We cannot get the email address from authentication provider.</value>
</data>
@ -1345,6 +1366,9 @@
<data name="validation.slug" xml:space="preserve">
<value>{property|upper} is not a valid slug.</value>
</data>
<data name="validation.url" xml:space="preserve">
<value>{property|upper} is not a valid URL.</value>
</data>
<data name="validation.valid" xml:space="preserve">
<value>{property|upper} is not a valid value.</value>
</data>

38
backend/src/Squidex.Shared/Texts.zh.resx

@ -196,9 +196,6 @@
<data name="common.calculatedDefaultValue" xml:space="preserve">
<value>计算的默认值</value>
</data>
<data name="common.clientd" xml:space="preserve">
<value>客户端 ID</value>
</data>
<data name="common.clientId" xml:space="preserve">
<value>客户端 ID</value>
</data>
@ -226,9 +223,6 @@
<data name="common.documentation" xml:space="preserve">
<value>Documentation</value>
</data>
<data name="common.editInNewTab" xml:space="preserve">
<value>Open in new tab</value>
</data>
<data name="common.editor" xml:space="preserve">
<value>编辑器</value>
</data>
@ -421,6 +415,9 @@
<data name="common.schemaId" xml:space="preserve">
<value>Schemas ID</value>
</data>
<data name="common.signoutRedirectUrl" xml:space="preserve">
<value>Signout Redirect URL</value>
</data>
<data name="common.signup" xml:space="preserve">
<value>注册</value>
</data>
@ -910,6 +907,15 @@
<data name="login.githubPrivateEmail" xml:space="preserve">
<value>您的邮箱在 Github 中设置为私有。请设置为公开以使用 Github 登录。</value>
</data>
<data name="login.test.headline" xml:space="preserve">
<value>Successful Test</value>
</data>
<data name="login.test.text" xml:space="preserve">
<value>Login Test was successful. You can save the settings now. It is recommended to make a final test with the actual login flow.</value>
</data>
<data name="login.test.title" xml:space="preserve">
<value>Login Test</value>
</data>
<data name="schemas.dateTimeCalculatedDefaultAndDefaultError" xml:space="preserve">
<value>计算出的默认值和默认值不能一起使用。</value>
</data>
@ -1016,7 +1022,7 @@
<value>制作</value>
</data>
<data name="setup.madeByCopyright" xml:space="preserve">
<value>Sebastian Stehle 和贡献者,2016-2021</value>
<value>Sebastian Stehle 和贡献者,2016-2024</value>
</data>
<data name="setup.ruleAppCreation.warningAdmins" xml:space="preserve">
<value>通过你的设置,只有管理员可以创建新的应用程序。如果你想改变这个设置 &lt;code&gt;UI__ONLYADMINCANCREATEAPPS=false&lt;/code&gt; 作为环境变量。</value>
@ -1054,6 +1060,12 @@
<data name="setup.title" xml:space="preserve">
<value>安装</value>
</data>
<data name="teams.appsAssigned" xml:space="preserve">
<value>Cannot delete team, when apps are assigned.</value>
</data>
<data name="teams.domainInUse" xml:space="preserve">
<value>Domain is already used for another team.</value>
</data>
<data name="users.accessDenied.text" xml:space="preserve">
<value>不允许此操作,您的帐户可能被锁定。</value>
</data>
@ -1117,6 +1129,12 @@
<data name="users.login.askAdmin" xml:space="preserve">
<value />
</data>
<data name="users.login.custom" xml:space="preserve">
<value>Enter your E-Mail Address to login with your Company Account and single sign on (SSO).</value>
</data>
<data name="users.login.emailBusinessPlaceholder" xml:space="preserve">
<value>Enter Business Email</value>
</data>
<data name="users.login.emailPlaceholder" xml:space="preserve">
<value>输入邮箱</value>
</data>
@ -1153,6 +1171,9 @@
<data name="users.logout.title" xml:space="preserve">
<value>注销</value>
</data>
<data name="users.noCustomDomain" xml:space="preserve">
<value>No authentication server registered.</value>
</data>
<data name="users.noEmailAddress" xml:space="preserve">
<value>We cannot get the email address from authentication provider.</value>
</data>
@ -1345,6 +1366,9 @@
<data name="validation.slug" xml:space="preserve">
<value>{property|upper} 不是有效的 slug。</value>
</data>
<data name="validation.url" xml:space="preserve">
<value>{property|upper} is not a valid URL.</value>
</data>
<data name="validation.valid" xml:space="preserve">
<value>{property|upper} 不是一个有效值。</value>
</data>

3
backend/src/Squidex.Web/Resources.cs

@ -137,6 +137,9 @@ public sealed class Resources
public bool CanChangeTeamPlan => Can(PermissionIds.TeamPlansChange);
// Team Auth
public bool CanChangeTeamAuth => Can(PermissionIds.TeamAuthChange);
// Backups
public bool CanRestoreBackup => Can(PermissionIds.AdminRestore);

2
backend/src/Squidex.Web/Squidex.Web.csproj

@ -16,7 +16,7 @@
<PackageReference Include="GraphQL" Version="7.8.0" />
<PackageReference Include="GraphQL.SystemTextJson" Version="7.8.0" />
<PackageReference Include="GraphQL.Server.Transports.AspNetCore" Version="7.7.1" />
<PackageReference Include="Meziantou.Analyzer" Version="2.0.146">
<PackageReference Include="Meziantou.Analyzer" Version="2.0.150">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

62
backend/src/Squidex/Areas/Api/Controllers/Teams/Models/AuthSchemeDto.cs

@ -0,0 +1,62 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Core.Teams;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.Validation;
using Squidex.Web;
namespace Squidex.Areas.Api.Controllers.Teams.Models;
[OpenApiRequest]
public sealed class AuthSchemeDto
{
/// <summary>
/// The domain name of your user accounts.
/// </summary>
[LocalizedRequired]
public string Domain { get; init; }
/// <summary>
/// The display name for buttons.
/// </summary>
[LocalizedRequired]
public string DisplayName { get; init; }
/// <summary>
/// The client ID.
/// </summary>
[LocalizedRequired]
public string ClientId { get; init; }
/// <summary>
/// The client secret.
/// </summary>
[LocalizedRequired]
public string ClientSecret { get; init; }
/// <summary>
/// The authority URL.
/// </summary>
[LocalizedRequired]
public string Authority { get; init; }
/// <summary>
/// The URL to redirect after a signout.
/// </summary>
public string? SignoutRedirectUrl { get; init; }
public AuthScheme ToDomain()
{
return SimpleMapper.Map(this, new AuthScheme());
}
public static AuthSchemeDto FromDomain(AuthScheme source)
{
return SimpleMapper.Map(source, new AuthSchemeDto());
}
}

46
backend/src/Squidex/Areas/Api/Controllers/Teams/Models/AuthSchemeResponseDto.cs

@ -0,0 +1,46 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Core.Teams;
using Squidex.Web;
namespace Squidex.Areas.Api.Controllers.Teams.Models;
public class AuthSchemeResponseDto : Resource
{
/// <summary>
/// The auth scheme if configured.
/// </summary>
public AuthSchemeDto? Scheme { get; set; }
public static AuthSchemeResponseDto FromDomain(Team team, Resources resources)
{
var result = new AuthSchemeResponseDto();
if (team.AuthScheme != null)
{
result.Scheme = AuthSchemeDto.FromDomain(team.AuthScheme);
}
return result.CreateLinks(resources);
}
private AuthSchemeResponseDto CreateLinks(Resources resources)
{
var values = new { team = resources.Team };
AddSelfLink(resources.Url<TeamsController>(x => nameof(x.GetTeamAuth), values));
if (resources.CanChangeTeamAuth)
{
AddPutLink("update",
resources.Url<TeamsController>(x => nameof(x.PutTeamAuth), values));
}
return this;
}
}

39
backend/src/Squidex/Areas/Api/Controllers/Teams/Models/AuthSchemeValueDto.cs

@ -0,0 +1,39 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Core.Teams;
using Squidex.Domain.Apps.Entities.Teams.Commands;
namespace Squidex.Areas.Api.Controllers.Teams.Models;
public class AuthSchemeValueDto
{
/// <summary>
/// The auth scheme if configured.
/// </summary>
public AuthSchemeDto? Scheme { get; set; }
public UpsertAuth ToCommand()
{
return new UpsertAuth
{
Scheme = Scheme?.ToDomain()
};
}
public static AuthSchemeValueDto FromDomain(Team source)
{
var result = new AuthSchemeValueDto();
if (source.AuthScheme != null)
{
result.Scheme = AuthSchemeDto.FromDomain(source.AuthScheme);
}
return result;
}
}

12
backend/src/Squidex/Areas/Api/Controllers/Teams/Models/TeamDto.cs

@ -91,6 +91,18 @@ public sealed class TeamDto : Resource
resources.Url<TeamPlansController>(x => nameof(x.GetTeamPlans), values));
}
if (resources.IsAllowed(PermissionIds.TeamAuthRead, team: values.team, additional: permissions))
{
AddGetLink("auth",
resources.Url<TeamsController>(x => nameof(x.GetTeamAuth), values));
}
if (resources.IsAllowed(PermissionIds.TeamDelete, team: values.team, additional: permissions))
{
AddDeleteLink("delete",
resources.Url<TeamsController>(x => nameof(x.DeleteTeam), values));
}
return this;
}
}

76
backend/src/Squidex/Areas/Api/Controllers/Teams/TeamsController.cs

@ -10,6 +10,7 @@ using Microsoft.Net.Http.Headers;
using Squidex.Areas.Api.Controllers.Teams.Models;
using Squidex.Domain.Apps.Core.Teams;
using Squidex.Domain.Apps.Entities;
using Squidex.Domain.Apps.Entities.Teams.Commands;
using Squidex.Infrastructure.Commands;
using Squidex.Shared;
using Squidex.Web;
@ -58,9 +59,9 @@ public sealed class TeamsController : ApiController
}
/// <summary>
/// Get an team by name.
/// Get an team by ID.
/// </summary>
/// <param name="team">The name of the team.</param>
/// <param name="team">The ID of the team.</param>
/// <response code="200">Teams returned.</response>
/// <response code="404">Team not found.</response>
[HttpGet]
@ -86,7 +87,6 @@ public sealed class TeamsController : ApiController
/// <param name="request">The team object that needs to be added to Squidex.</param>
/// <response code="201">Team created.</response>
/// <response code="400">Team request not valid.</response>
/// <response code="409">Team name is already in use.</response>
/// <remarks>
/// You can only create an team when you are authenticated as a user (OpenID implicit flow).
/// You will be assigned as owner of the new team automatically.
@ -106,7 +106,7 @@ public sealed class TeamsController : ApiController
/// <summary>
/// Update the team.
/// </summary>
/// <param name="team">The name of the team to update.</param>
/// <param name="team">The ID of the team to update.</param>
/// <param name="request">The values to update.</param>
/// <response code="200">Team updated.</response>
/// <response code="400">Team request not valid.</response>
@ -123,6 +123,74 @@ public sealed class TeamsController : ApiController
return Ok(response);
}
/// <summary>
/// Get the team auth settings.
/// </summary>
/// <param name="team">The ID of the team.</param>
/// <response code="200">Teams returned.</response>
/// <response code="404">Team not found.</response>
[HttpGet]
[Route("teams/{team}/auth")]
[ProducesResponseType(typeof(AuthSchemeResponseDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(PermissionIds.TeamAuthRead)]
[ApiCosts(0)]
public IActionResult GetTeamAuth(string team)
{
var response = Deferred.Response(() =>
{
return AuthSchemeResponseDto.FromDomain(Team, Resources);
});
Response.Headers[HeaderNames.ETag] = Team.ToEtag();
return Ok(response);
}
/// <summary>
/// Update the team auth.
/// </summary>
/// <param name="team">The ID of the team to update.</param>
/// <param name="request">The values to update.</param>
/// <response code="200">Team updated.</response>
/// <response code="400">Team request not valid.</response>
/// <response code="404">Team not found.</response>
[HttpPut]
[Route("teams/{team}/auth")]
[ProducesResponseType(typeof(AuthSchemeResponseDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(PermissionIds.TeamAuthChange)]
[ApiCosts(0)]
public async Task<IActionResult> PutTeamAuth(string team, [FromBody] AuthSchemeValueDto request)
{
var command = request.ToCommand();
var response = await InvokeCommandAsync(command, x =>
{
return AuthSchemeResponseDto.FromDomain(x, Resources);
});
return Ok(response);
}
/// <summary>
/// Delete the team.
/// </summary>
/// <param name="team">The ID of the team to delete.</param>
/// <response code="204">Team deleted.</response>
/// <response code="404">Team not found.</response>
[HttpDelete]
[Route("teams/{team}/")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ApiPermission(PermissionIds.TeamDelete)]
[ApiCosts(0)]
public async Task<IActionResult> DeleteTeam(string team)
{
var command = new DeleteTeam();
await CommandBus.PublishAsync(command, HttpContext.RequestAborted);
return NoContent();
}
private Task<TeamDto> InvokeCommandAsync(ICommand command)
{
return InvokeCommandAsync(command, x =>

2
backend/src/Squidex/Areas/Api/Views/Shared/Docs.cshtml

@ -37,7 +37,7 @@
<script src="~/scripts/redoc.standalone.js"></script>
<script>
Redoc.init('@Url.Content(Model!.Specification)', {
Redoc.init('@Url.Content(Model.Specification)', {
theme: {
colors: {
primary: {

20
backend/src/Squidex/Areas/IdentityServer/Config/Dynamic/DynamicOpenIdConnectHandler.cs

@ -0,0 +1,20 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.Extensions.Options;
namespace Squidex.Areas.IdentityServer.Config;
public sealed class DynamicOpenIdConnectHandler : OpenIdConnectHandler
{
public DynamicOpenIdConnectHandler(IOptionsMonitor<DynamicOpenIdConnectOptions> options, ILoggerFactory logger, HtmlEncoder htmlEncoder, UrlEncoder encoder)
: base(options, logger, htmlEncoder, encoder)
{
}
}

14
backend/src/Squidex/Areas/IdentityServer/Config/Dynamic/DynamicOpenIdConnectOptions.cs

@ -0,0 +1,14 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
namespace Squidex.Areas.IdentityServer.Config;
public sealed class DynamicOpenIdConnectOptions : OpenIdConnectOptions
{
}

236
backend/src/Squidex/Areas/IdentityServer/Config/Dynamic/DynamicSchemeProvider.cs

@ -0,0 +1,236 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Options;
using Squidex.Config;
using Squidex.Config.Authentication;
using Squidex.Domain.Apps.Core.Teams;
using Squidex.Domain.Apps.Entities;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Json;
#pragma warning disable RECS0082 // Parameter has the same name as a member and hides it
#pragma warning disable SA1313 // Parameter names should begin with lower-case letter
namespace Squidex.Areas.IdentityServer.Config;
public sealed class DynamicSchemeProvider : AuthenticationSchemeProvider, IOptionsMonitor<DynamicOpenIdConnectOptions>
{
private static readonly string[] UrlPrefixes = ["signin-", "signout-callback-", "signout-"];
private readonly IAppProvider appProvider;
private readonly IHttpContextAccessor httpContextAccessor;
private readonly IDistributedCache dynamicCache;
private readonly IJsonSerializer jsonSerializer;
private readonly OpenIdConnectPostConfigureOptions configure;
public DynamicOpenIdConnectOptions CurrentValue => null!;
private sealed record SchemeResult(AuthenticationScheme Scheme, DynamicOpenIdConnectOptions Options);
public DynamicSchemeProvider(
IAppProvider appProvider,
IHttpContextAccessor httpContextAccessor,
IDistributedCache dynamicCache,
IJsonSerializer jsonSerializer,
OpenIdConnectPostConfigureOptions configure,
IOptions<AuthenticationOptions> options)
: base(options)
{
this.appProvider = appProvider;
this.httpContextAccessor = httpContextAccessor;
this.dynamicCache = dynamicCache;
this.jsonSerializer = jsonSerializer;
this.configure = configure;
}
public async Task<string> AddTemporarySchemeAsync(AuthScheme scheme,
CancellationToken ct = default)
{
var id = Guid.NewGuid().ToString();
var serialized = jsonSerializer.SerializeToBytes(scheme);
var options = new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10)
};
await dynamicCache.SetAsync(CacheKey(id), serialized, options, ct);
return id;
}
public async Task<AuthenticationScheme?> GetSchemaByEmailAddressAsync(string email)
{
if (string.IsNullOrWhiteSpace(email))
{
return null;
}
var parts = email.Split('@');
if (parts.Length != 2)
{
return null;
}
var team = await appProvider.GetTeamByAuthDomainAsync(parts[1], default);
if (team?.AuthScheme != null)
{
return CreateScheme(team.Id.ToString(), team.AuthScheme).Scheme;
}
return null;
}
public override async Task<AuthenticationScheme?> GetSchemeAsync(string name)
{
var result = await GetSchemeCoreAsync(name, default);
if (result != null)
{
return result.Scheme;
}
return await base.GetSchemeAsync(name);
}
public override async Task<IEnumerable<AuthenticationScheme>> GetRequestHandlerSchemesAsync()
{
var result = (await base.GetRequestHandlerSchemesAsync()).ToList();
if (httpContextAccessor.HttpContext == null)
{
return result;
}
var path = httpContextAccessor.HttpContext.Request.Path.Value;
if (string.IsNullOrWhiteSpace(path))
{
return result;
}
var lastSegment = path.Split('/', StringSplitOptions.RemoveEmptyEntries).LastOrDefault() ?? string.Empty;
foreach (var prefix in UrlPrefixes)
{
if (lastSegment.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
{
var name = lastSegment[prefix.Length..];
var scheme = await GetSchemeCoreAsync(name, httpContextAccessor.HttpContext.RequestAborted);
if (scheme != null)
{
result.Add(scheme.Scheme);
}
}
}
return result;
}
public DynamicOpenIdConnectOptions Get(string? name)
{
if (string.IsNullOrWhiteSpace(name))
{
return new DynamicOpenIdConnectOptions();
}
var scheme = GetSchemeCoreAsync(name, default).Result;
return scheme?.Options ?? new DynamicOpenIdConnectOptions();
}
private async Task<SchemeResult?> GetSchemeCoreAsync(string name,
CancellationToken ct)
{
if (!Guid.TryParse(name, out _))
{
return null;
}
var cacheKey = ("DYNAMIC_SCHEME", name);
if (httpContextAccessor.HttpContext?.Items.TryGetValue(cacheKey, out var cached) == true)
{
return cached as SchemeResult;
}
var scheme =
await GetSchemeByTeamAsync(name, ct) ??
await GetSchemeByTempNameAsync(name, ct);
var result =
scheme != null ?
CreateScheme(name, scheme) :
null;
if (httpContextAccessor.HttpContext != null)
{
httpContextAccessor.HttpContext.Items[cacheKey] = result;
}
return result;
}
private async Task<AuthScheme?> GetSchemeByTeamAsync(string name,
CancellationToken ct)
{
var app = await appProvider.GetTeamAsync(DomainId.Create(name), ct);
return app?.AuthScheme;
}
private async Task<AuthScheme?> GetSchemeByTempNameAsync(string name,
CancellationToken ct)
{
var value = await dynamicCache.GetAsync(CacheKey(name), ct);
return value != null ? jsonSerializer.Deserialize<AuthScheme>(new MemoryStream(value)) : null;
}
private SchemeResult CreateScheme(string name, AuthScheme config)
{
var scheme = new AuthenticationScheme(name, config.DisplayName, typeof(DynamicOpenIdConnectHandler));
var options = new DynamicOpenIdConnectOptions
{
Events = new OidcHandler(new MyIdentityOptions
{
OidcOnSignoutRedirectUrl = config.SignoutRedirectUrl
}),
Authority = config.Authority,
CallbackPath = new PathString($"/signin-{name}"),
ClientId = config.ClientId,
ClientSecret = config.ClientSecret,
RemoteSignOutPath = new PathString($"/signout-{name}"),
RequireHttpsMetadata = false,
ResponseType = "code",
SignedOutRedirectUri = new PathString($"/signout-callback-{name}")
};
configure.PostConfigure(name, options);
return new SchemeResult(scheme, options);
}
public IDisposable? OnChange(Action<DynamicOpenIdConnectOptions, string?> listener)
{
return null;
}
private static string CacheKey(string id)
{
return $"AUTH_SCHEMES_{id}";
}
}

7
backend/src/Squidex/Areas/IdentityServer/Config/IdentityServerServices.cs

@ -7,6 +7,7 @@
using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.DataProtection.KeyManagement;
using Microsoft.AspNetCore.DataProtection.Repositories;
@ -59,6 +60,12 @@ public static class IdentityServerServices
services.AddSingletonAs<CreateAdminInitializer>()
.AsSelf();
services.AddSingletonAs<OpenIdConnectPostConfigureOptions>()
.AsSelf();
services.AddSingletonAs<DynamicSchemeProvider>()
.AsSelf().As<IAuthenticationSchemeProvider>().As<IOptionsMonitor<DynamicOpenIdConnectOptions>>();
services.ConfigureOptions<DefaultKeyStore>();
services.Configure<IdentityOptions>(options =>

61
backend/src/Squidex/Areas/IdentityServer/Controllers/Account/AccountController.cs

@ -8,6 +8,7 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using Squidex.Areas.IdentityServer.Config;
using Squidex.Config;
using Squidex.Domain.Users;
using Squidex.Infrastructure;
@ -21,14 +22,17 @@ namespace Squidex.Areas.IdentityServer.Controllers.Account;
[AutoValidateAntiforgeryToken]
public sealed class AccountController : IdentityServerController
{
private readonly DynamicSchemeProvider schemes;
private readonly IUserService userService;
private readonly MyIdentityOptions identityOptions;
public AccountController(
DynamicSchemeProvider schemes,
IUserService userService,
IOptions<MyIdentityOptions> identityOptions)
{
this.identityOptions = identityOptions.Value;
this.schemes = schemes;
this.userService = userService;
}
@ -129,27 +133,20 @@ public sealed class AccountController : IdentityServerController
return RedirectToAction(nameof(LogoutCompleted));
}
[HttpGet]
[Route("account/signup/")]
public Task<IActionResult> Signup(string? returnUrl = null)
{
return LoginViewAsync(returnUrl, false, false);
}
[HttpGet]
[Route("account/login/")]
public Task<IActionResult> Login(string? returnUrl = null)
public Task<IActionResult> Login()
{
return LoginViewAsync(returnUrl, true, false);
return LoginViewAsync(RequestType.Get);
}
[HttpPost]
[Route("account/login/")]
public async Task<IActionResult> Login(LoginModel model, string? returnUrl = null)
public async Task<IActionResult> Login(LoginModel model, string? returnUrl)
{
if (!ModelState.IsValid)
{
return await LoginViewAsync(returnUrl, true, true);
return await LoginViewAsync(RequestType.Login);
}
var result = await SignInManager.PasswordSignInAsync(model.Email, model.Password, true, true);
@ -160,7 +157,9 @@ public sealed class AccountController : IdentityServerController
}
else if (!result.Succeeded)
{
return await LoginViewAsync(returnUrl, true, true);
ModelState.AddModelError(string.Empty, T.Get("users.login.error"));
return await LoginViewAsync(RequestType.Login);
}
else
{
@ -168,15 +167,44 @@ public sealed class AccountController : IdentityServerController
}
}
private async Task<IActionResult> LoginViewAsync(string? returnUrl, bool isLogin, bool isFailed)
[HttpPost]
[Route("account/login-dynamic/")]
public async Task<IActionResult> LoginDynamic(LoginDynamicModel model, string? returnUrl = null)
{
if (!ModelState.IsValid)
{
return await LoginViewAsync(RequestType.LoginCustom);
}
var scheme = await schemes.GetSchemaByEmailAddressAsync(model.DynamicEmail);
if (scheme != null)
{
var provider = scheme.Name;
var challengeRedirectUrl = Url.Action(nameof(ExternalCallback), new { returnUrl });
var challengeProperties = SignInManager.ConfigureExternalAuthenticationProperties(provider, challengeRedirectUrl);
return Challenge(challengeProperties, provider);
}
ModelState.AddModelError(string.Empty, T.Get("users.noCustomDomain")!);
return await LoginViewAsync(RequestType.LoginCustom);
}
private async Task<IActionResult> LoginViewAsync(RequestType requestType)
{
string? returnUrl = HttpContext.Request.Query["returnUrl"];
// If password authentication is enabled we always show the page.
var allowPasswordAuth = identityOptions.AllowPasswordAuth;
var allowCustomDomains = identityOptions.AllowCustomDomains;
var externalProviders = await SignInManager.GetExternalProvidersAsync();
// If there is only one external authentication provider, we can redirect just directly.
if (externalProviders.Count == 1 && !allowPasswordAuth)
if (externalProviders.Count == 1 && !allowPasswordAuth && !allowCustomDomains)
{
var provider = externalProviders[0].AuthenticationScheme;
@ -190,9 +218,10 @@ public sealed class AccountController : IdentityServerController
var vm = new LoginVM
{
ExternalProviders = externalProviders,
IsFailed = isFailed,
IsLogin = isLogin,
IsLogin = HttpContext.Request.Query["signup"] != "true",
HasPasswordAuth = allowPasswordAuth,
HasCustomAuth = allowCustomDomains,
RequestType = requestType,
ReturnUrl = returnUrl
};

16
backend/src/Squidex/Areas/IdentityServer/Controllers/Account/LoginDynamicModel.cs

@ -0,0 +1,16 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Infrastructure.Validation;
namespace Squidex.Areas.IdentityServer.Controllers.Account;
public sealed class LoginDynamicModel
{
[LocalizedRequired]
public string DynamicEmail { get; set; }
}

21
backend/src/Squidex/Areas/IdentityServer/Controllers/Account/LoginVM.cs

@ -7,17 +7,34 @@
namespace Squidex.Areas.IdentityServer.Controllers.Account;
#pragma warning disable MA0048 // File name must match type name
public class LoginVM
{
public string? ReturnUrl { get; set; }
public bool IsLogin { get; set; }
public string? Email { get; set; }
public string? DynamicEmail { get; set; }
public bool IsFailed { get; set; }
public string? Password { get; set; }
public bool IsLogin { get; set; }
public bool HasPasswordAuth { get; set; }
public bool HasCustomAuth { get; set; }
public bool HasExternalLogin => ExternalProviders.Any();
public RequestType RequestType { get; set; }
public IReadOnlyList<ExternalProvider> ExternalProviders { get; set; }
}
public enum RequestType
{
Get,
Login,
LoginCustom
}

37
backend/src/Squidex/Areas/IdentityServer/Controllers/Error/ErrorController.cs

@ -26,20 +26,35 @@ public sealed class ErrorController : IdentityServerController
vm.ErrorMessage = response?.ErrorDescription;
vm.ErrorCode = response?.Error;
if (string.IsNullOrWhiteSpace(vm.ErrorMessage))
if (!string.IsNullOrWhiteSpace(vm.ErrorMessage))
{
var exception = HttpContext.Features.Get<IExceptionHandlerFeature>()?.Error;
if (exception is DomainException domainException1)
{
vm.ErrorMessage = domainException1.Message;
}
else if (exception?.InnerException is DomainException domainException2)
{
vm.ErrorMessage = domainException2.Message;
}
return View("Error", vm);
}
var source = HttpContext.Features.Get<IExceptionHandlerFeature>();
if (source == null)
{
return View("Error", vm);
}
var exception = source.Error;
while (exception?.InnerException != null)
{
exception = exception.InnerException;
}
if (exception is DomainException || IsTestEndpoint(source))
{
vm.ErrorMessage = exception?.Message;
}
return View("Error", vm);
}
private static bool IsTestEndpoint(IExceptionHandlerFeature source)
{
return source.Endpoint is RouteEndpoint route && route.RoutePattern.RawText == "identity-server/test";
}
}

6
backend/src/Squidex/Areas/IdentityServer/Controllers/Profile/ProfileVM.cs

@ -23,6 +23,12 @@ public sealed class ProfileVM
public string? Project { get; set; }
public string? OldPassword { get; set; }
public string? Password { get; set; }
public string? PasswordConfirm { get; set; }
public string? ClientSecret { get; set; }
public string? ErrorMessage { get; set; }

4
backend/src/Squidex/Areas/IdentityServer/Controllers/Setup/SetupVM.cs

@ -17,6 +17,10 @@ public sealed class SetupVM
public string? ErrorMessage { get; set; }
public string? Password { get; set; }
public string? PasswordConfirm { get; set; }
public bool IsValidHttps { get; set; }
public bool IsAssetStoreFtp { get; set; }

40
backend/src/Squidex/Areas/IdentityServer/Controllers/Test/TestController.cs

@ -0,0 +1,40 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Microsoft.AspNetCore.Mvc;
using Squidex.Areas.IdentityServer.Config;
using Squidex.Domain.Apps.Core.Teams;
namespace Squidex.Areas.IdentityServer.Controllers.Test;
public sealed class TestController : IdentityServerController
{
private readonly DynamicSchemeProvider schemes;
public TestController(DynamicSchemeProvider schemes)
{
this.schemes = schemes;
}
[Route("test/")]
public async Task<IActionResult> Test(
[FromQuery] AuthScheme scheme)
{
var id = await schemes.AddTemporarySchemeAsync(scheme, default);
var challengeRedirectUrl = Url.Action(nameof(Success));
var challengeProperties = SignInManager.ConfigureExternalAuthenticationProperties(id, challengeRedirectUrl);
return Challenge(challengeProperties, id);
}
[Route("test/success/")]
public IActionResult Success()
{
return View();
}
}

4
backend/src/Squidex/Areas/IdentityServer/Views/Account/Consent.cshtml

@ -11,7 +11,7 @@
}
}
<form asp-controller="Account" asp-action="Consent" asp-route-returnurl="@Model!.ReturnUrl" method="post">
<form asp-controller="Account" asp-action="Consent" asp-route-returnurl="@Model.ReturnUrl" method="post">
@Html.AntiForgeryToken()
<h2>@T.Get("users.consent.headline")</h2>
@ -43,7 +43,7 @@
<input type="checkbox" id="consentToCookies" name="consentToCookies" value="True" />
</div>
<div class="col">
@Html.Raw(T.Get("users.consent.cookiesText", new { privacyUrl = Model!.PrivacyUrl }))
@Html.Raw(T.Get("users.consent.cookiesText", new { privacyUrl = Model.PrivacyUrl }))
</div>
</div>
</div>

76
backend/src/Squidex/Areas/IdentityServer/Views/Account/Login.cshtml

@ -1,7 +1,8 @@
@model Squidex.Areas.IdentityServer.Controllers.Account.LoginVM
@using Squidex.Areas.IdentityServer.Controllers.Account
@model Squidex.Areas.IdentityServer.Controllers.Account.LoginVM
@{
var action = Model!.IsLogin ? T.Get("common.login") : T.Get("common.signup");
var action = Model.IsLogin ? T.Get("common.login") : T.Get("common.signup");
ViewBag.Title = action;
}
@ -10,34 +11,35 @@
<div class="container">
<div class="row text-center">
<div class="btn-group profile-headline">
@if (Model!.IsLogin)
@if (Model.IsLogin)
{
<a class="btn btn-toggle btn-primary" asp-controller="Account" asp-action="Login" asp-route-returnurl="@Model!.ReturnUrl">@T.Get("common.login")</a>
<a class="btn btn-toggle btn-primary">@T.Get("common.login")</a>
}
else
{
<a class="btn btn-toggle" asp-controller="Account" asp-action="Login" asp-route-returnurl="@Model!.ReturnUrl">@T.Get("common.login")</a>
<a class="btn btn-toggle" asp-controller="Account" asp-action="Login" asp-route-returnurl="@Model.ReturnUrl">@T.Get("common.login")</a>
}
@if (!Model!.IsLogin)
@if (!Model.IsLogin)
{
<a class="btn btn-toggle btn-primary" asp-controller="Account" asp-action="Signup" asp-route-returnurl="@Model!.ReturnUrl">@T.Get("common.signup")</a>
<a class="btn btn-toggle btn-primary">@T.Get("common.signup")</a>
}
else
{
<a class="btn btn-toggle" asp-controller="Account" asp-action="Signup" asp-route-returnurl="@Model!.ReturnUrl">@T.Get("common.signup")</a>
<a class="btn btn-toggle" asp-controller="Account" asp-action="Login" asp-route-returnurl="@Model.ReturnUrl" asp-route-signup="true">@T.Get("common.signup")</a>
}
</div>
</div>
</div>
<form asp-controller="Account" asp-action="External" asp-route-returnurl="@Model!.ReturnUrl" method="post">
<form asp-controller="Account" asp-action="External" asp-route-returnurl="@Model.ReturnUrl" method="post">
@Html.AntiForgeryToken()
@foreach (var provider in Model!.ExternalProviders)
@foreach (var provider in Model.ExternalProviders)
{
var schema = provider.AuthenticationScheme.ToLowerInvariant();
<div class="form-group">
<div class="form-group mb-2">
<button class="btn external-button btn-block btn btn-@schema" type="submit" name="provider" value="@provider.AuthenticationScheme">
<i class="icon-@schema external-icon"></i> @Html.Raw(T.Get("users.login.loginWith", new { action, provider = provider.DisplayName }))
</button>
@ -45,31 +47,30 @@
}
</form>
@if (Model!.HasExternalLogin && Model!.HasPasswordAuth)
@if (Model.HasPasswordAuth)
{
<div class="profile-separator">
<div class="profile-separator-text">@T.Get("users.login.separator")</div>
</div>
}
@if (Model!.HasPasswordAuth)
{
if (Model!.IsLogin)
if (Model.IsLogin)
{
if (Model!.IsFailed)
@if (Model.RequestType == RequestType.Login)
{
<div class="form-alert form-alert-error">@T.Get("users.login.error")</div>
<div asp-validation-summary="ModelOnly" class="form-alert form-alert-error"></div>
}
<form asp-controller="Account" asp-action="Login" asp-route-returnurl="@Model!.ReturnUrl" method="post">
<form asp-controller="Account" asp-action="Login" asp-route-returnurl="@Model.ReturnUrl" method="post">
@Html.AntiForgeryToken()
<div class="form-group">
<input type="email" class="form-control" name="email" id="email" placeholder="@T.Get("users.login.emailPlaceholder")" />
<div error-for="Email"></div>
<input asp-for="Email" type="email" class="form-control" placeholder="@T.Get("users.login.emailPlaceholder")" />
</div>
<div class="form-group">
<input type="password" class="form-control" name="password" id="password" placeholder="@T.Get("users.login.passwordPlaceholder")" />
<div error-for="Password"></div>
<input asp-for="Password" type="password" class="form-control" placeholder="@T.Get("users.login.passwordPlaceholder")" />
</div>
<button type="submit" data-testid="login-button" class="btn btn-block btn-primary">@action</button>
@ -81,12 +82,39 @@
}
}
@if (Model!.IsLogin)
@if (Model.HasCustomAuth && Model.IsLogin)
{
<div class="profile-separator">
<div class="profile-separator-text">@T.Get("users.login.separator")</div>
</div>
@if (Model.RequestType == RequestType.LoginCustom)
{
<div asp-validation-summary="ModelOnly" class="form-alert form-alert-error"></div>
}
<div class="mb-2">
<small class="text-muted form-text">@T.Get("users.login.custom")</small>
</div>
<form asp-controller="Account" asp-action="LoginDynamic" asp-route-returnurl="@Model.ReturnUrl" method="post">
@Html.AntiForgeryToken()
<div class="form-group">
<div error-for="DynamicEmail"></div>
<input asp-for="DynamicEmail" type="email" class="form-control" placeholder="@T.Get("users.login.emailBusinessPlaceholder")" />
</div>
<button type="submit" data-testid="dynamic-button" class="btn btn-block btn-primary">@action</button>
</form>
}
@if (Model.IsLogin)
{
<p class="profile-footer">
@T.Get("users.login.noAccountSignupQuestion")
<a asp-controller="Account" asp-action="Signup" asp-route-returnurl="@Model!.ReturnUrl">
<a asp-controller="Account" asp-action="Signup" asp-route-returnurl="@Model.ReturnUrl">
@T.Get("users.login.noAccountSignupAction")
</a>
</p>
@ -96,7 +124,7 @@
<p class="profile-footer">
@T.Get("users.login.noAccountLoginQuestion")
<a asp-controller="Account" asp-action="Login" asp-route-returnurl="@Model!.ReturnUrl">
<a asp-controller="Account" asp-action="Login" asp-route-returnurl="@Model.ReturnUrl">
@T.Get("users.login.noAccountLoginAction")
</a>
</p>

4
backend/src/Squidex/Areas/IdentityServer/Views/Error/Error.cshtml

@ -9,9 +9,9 @@
<h1 class="splash-h1">@T.Get("users.error.headline")</h1>
<p class="splash-text">
@if (Model!.ErrorMessage != null)
@if (Model.ErrorMessage != null)
{
@Model!.ErrorMessage
<textarea class="form-control text-start mono" style="height: 200px; font-size: 85%" readonly>@Model.ErrorMessage</textarea>
}
else
{

95
backend/src/Squidex/Areas/IdentityServer/Views/Profile/Profile.cshtml

@ -2,39 +2,29 @@
@{
ViewBag.Title = T.Get("users.profile.title");
void RenderValidation(string field)
{
@if (ViewContext.ViewData.ModelState[field]?.ValidationState == Microsoft.AspNetCore.Mvc.ModelBinding.ModelValidationState.Invalid)
{
<div class="errors-container">
<span class="errors">@Html.ValidationMessage(field)</span>
</div>
}
}
}
<h1>@T.Get("users.profile.headline")</h1>
<h2>@T.Get("users.profile.pii")</h2>
@if (!string.IsNullOrWhiteSpace(Model!.SuccessMessage))
@if (!string.IsNullOrWhiteSpace(Model.SuccessMessage))
{
<div class="form-alert form-alert-success" id="success">
@Model!.SuccessMessage
@Model.SuccessMessage
</div>
}
@if (!string.IsNullOrWhiteSpace(Model!.ErrorMessage))
@if (!string.IsNullOrWhiteSpace(Model.ErrorMessage))
{
<div class="form-alert form-alert-error">
@Model!.ErrorMessage
@Model.ErrorMessage
</div>
}
<div class="row profile-section-sm">
<div class="col profile-picture-col">
<img class="profile-picture" src="api/users/{Model!.Id}/picture/?q={@Guid.NewGuid()}" />
<img class="profile-picture" src="api/users/{Model.Id}/picture/?q={@Guid.NewGuid()}" />
</div>
<div class="col">
<form id="pictureForm" class="profile-picture-form" asp-controller="Profile" asp-action="UploadPicture" method="post" enctype="multipart/form-data">
@ -55,17 +45,15 @@
<div class="form-group">
<label asp-for="Email">@T.Get("common.email")</label>
@{ RenderValidation("Email"); }
<input type="email" class="form-control" asp-for="Email" />
<div error-for="Email"></div>
<input asp-for="Email" type="email" class="form-control" />
</div>
<div class="form-group">
<label asp-for="DisplayName">@T.Get("common.displayName")</label>
@{ RenderValidation("DisplayName"); }
<input type="text" class="form-control" asp-for="DisplayName" />
<div error-for="DisplayName"></div>
<input asp-for="DisplayName" type="text" class="form-control" />
</div>
<div class="form-group">
@ -140,7 +128,7 @@
</div>
}
@if (Model!.ExternalProviders.Any())
@if (Model.ExternalProviders.Any())
{
<hr />
@ -153,7 +141,7 @@
<col style="width: 100%;" />
<col style="width: 100px;" />
</colgroup>
@foreach (var login in Model!.ExternalLogins)
@foreach (var login in Model.ExternalLogins)
{
<tr>
<td>
@ -163,7 +151,7 @@
<span class="truncate">@login.ProviderDisplayName</span>
</td>
<td class="text-right">
@if (Model!.ExternalLogins.Count > 1 || Model!.HasPassword)
@if (Model.ExternalLogins.Count > 1 || Model.HasPassword)
{
<form asp-controller="Profile" asp-action="RemoveLogin" method="post">
@Html.AntiForgeryToken()
@ -184,7 +172,7 @@
<form asp-controller="Profile" asp-action="AddLogin" method="post">
@Html.AntiForgeryToken()
@foreach (var provider in Model!.ExternalProviders.Where(x => Model!.ExternalLogins.All(y => x.AuthenticationScheme != y.LoginProvider)))
@foreach (var provider in Model.ExternalProviders.Where(x => Model.ExternalLogins.All(y => x.AuthenticationScheme != y.LoginProvider)))
{
var schema = provider.AuthenticationScheme.ToLowerInvariant();
@ -196,40 +184,37 @@
</div>
}
@if (Model!.HasPasswordAuth)
@if (Model.HasPasswordAuth)
{
<hr />
<div class="profile-section">
<h2>@T.Get("users.profile.passwordTitle")</h2>
@if (Model!.HasPassword)
@if (Model.HasPassword)
{
<form class="profile-form" asp-controller="Profile" asp-action="ChangePassword" method="post">
@Html.AntiForgeryToken()
<div class="form-group">
<label for="oldPassword">@T.Get("common.oldPassword")</label>
@{ RenderValidation("OldPassword"); }
<label asp-for="OldPassword">@T.Get("common.oldPassword")</label>
<input type="password" class="form-control" name="oldPassword" id="oldPassword" />
<div error-for="OldPassword"></div>
<input asp-for="OldPassword" type="password" class="form-control" />
</div>
<div class="form-group">
<label for="password">@T.Get("common.password")</label>
<label asp-for="Password">@T.Get("common.password")</label>
@{ RenderValidation("Password"); }
<input type="password" class="form-control" name="password" id="password" />
<div error-for="Password"></div>
<input asp-for="Password" type="password" class="form-control" />
</div>
<div class="form-group">
<label for="passwordConfirm">@T.Get("users.profile.confirmPassword")</label>
@{ RenderValidation("PasswordConfirm"); }
<label asp-for="PasswordConfirm">@T.Get("users.profile.confirmPassword")</label>
<input type="password" class="form-control" name="passwordConfirm" id="passwordConfirm" />
<div error-for="PasswordConfirm"></div>
<input asp-for="PasswordConfirm" type="password" class="form-control" />
</div>
<div class="form-group">
@ -243,19 +228,17 @@
@Html.AntiForgeryToken()
<div class="form-group">
<label for="password">@T.Get("common.password")</label>
<label asp-for="Password">@T.Get("common.password")</label>
@{ RenderValidation("Password"); }
<input type="password" class="form-control" name="password" id="password" />
<div error-for="Password"></div>
<input asp-for="Password" type="password" class="form-control" />
</div>
<div class="form-group">
<label for="passwordConfirm">@T.Get("users.profile.confirmPassword")</label>
@{ RenderValidation("PasswordConfirm"); }
<label asp-for="PasswordConfirm">@T.Get("users.profile.confirmPassword")</label>
<input type="password" class="form-control" name="passwordConfirm" id="passwordConfirm" />
<div error-for="PasswordConfirm"></div>
<input asp-for="PasswordConfirm" type="password" class="form-control" />
</div>
<div class="form-group">
@ -277,14 +260,14 @@
<div class="col-8">
<label for="clientId">@T.Get("common.clientId")</label>
<input class="form-control" name="clientId" id="clientId" readonly value="@Model!.Id" />
<input class="form-control" name="clientId" id="clientId" readonly value="@Model.Id" />
</div>
</div>
<div class="row g-2 form-group">
<div class="col-8">
<label for="clientSecret">@T.Get("common.clientSecret")</label>
<input class="form-control" name="clientSecret" id="clientSecret" readonly value="@Model!.ClientSecret" />
<input class="form-control" name="clientSecret" id="clientSecret" readonly value="@Model.ClientSecret" />
</div>
<div class="col-4 pl-2">
<label for="generate">&nbsp;</label>
@ -309,20 +292,16 @@
@Html.AntiForgeryToken()
<div class="mb-2" id="properties">
@for (var i = 0; i < Model!.Properties.Count; i++)
@for (var i = 0; i < Model.Properties.Count; i++)
{
<div class="row g-2 form-group">
<div class="col-5 pr-2">
@{ RenderValidation($"Properties[{i}].Name"); }
<input type="text" class="form-control" asp-for="Properties[i].Name" />
<div error-for="Properties[i].Name"></div>
<input asp-for="Properties[i].Name" type="text" class="form-control" />
</div>
<div class="col pr-2">
@{ RenderValidation($"Properties[{i}].Value"); }
<input type="text" class="form-control" asp-for="Properties[i].Value" />
<div error-for="Properties[i].Value"></div>
<input asp-for="Properties[i].Value" type="text" class="form-control" />
</div>
<div class="col-auto">
<button type="button" class="btn btn-text-danger remove-item">

111
backend/src/Squidex/Areas/IdentityServer/Views/Setup/Setup.cshtml

@ -3,65 +3,55 @@
@{
ViewBag.Title = T.Get("setup.title");
void RenderValidation(string field)
{
@if (ViewContext.ViewData.ModelState[field]?.ValidationState == Microsoft.AspNetCore.Mvc.ModelBinding.ModelValidationState.Invalid)
{
<div class="errors-container">
<span class="errors">@Html.ValidationMessage(field)</span>
</div>
}
}
void RenderRuleAsSuccess(string message)
{
<div class="row mt-4">
<div class="col-auto">
<div class="status-icon status-icon-success mt-2">
<i class="icon-checkmark"></i>
</div>
<div class="col-auto">
<div class="status-icon status-icon-success mt-2">
<i class="icon-checkmark"></i>
</div>
</div>
<div class="col">
<div>
@Html.Raw(message)
</div>
<div class="col">
<div>
@Html.Raw(message)
</div>
</div>
</div>
}
void RenderRuleAsCritical(string message)
{
<div class="row mt-4">
<div class="col-auto">
<div class="status-icon status-icon-failed mt-2">
<i class="icon-exclamation"></i>
</div>
<div class="col-auto">
<div class="status-icon status-icon-failed mt-2">
<i class="icon-exclamation"></i>
</div>
</div>
<div class="col">
<div>
<strong>@T.Get("common.critical")</strong>: @Html.Raw(message)
</div>
<div class="col">
<div>
<strong>@T.Get("common.critical")</strong>: @Html.Raw(message)
</div>
</div>
</div>
}
void RenderRuleAsWarning(string message)
{
<div class="row mt-4">
<div class="col-auto">
<div class="status-icon status-icon-warning mt-2">
<i class="icon-exclamation"></i>
</div>
<div class="col-auto">
<div class="status-icon status-icon-warning mt-2">
<i class="icon-exclamation"></i>
</div>
</div>
<div class="col">
<div>
<strong>@T.Get("common.warning")</strong>: @Html.Raw(message)
</div>
<div class="col">
<div>
<strong>@T.Get("common.warning")</strong>: @Html.Raw(message)
</div>
</div>
</div>
}
}
@ -76,7 +66,7 @@
<div class="profile-section">
<h2>@T.Get("setup.rules.headline")</h2>
@if (Model!.IsValidHttps)
@if (Model.IsValidHttps)
{
RenderRuleAsSuccess(T.Get("setup.ruleHttps.success"));
}
@ -85,16 +75,16 @@
RenderRuleAsCritical(T.Get("setup.ruleHttps.failure"));
}
@if (Model!.BaseUrlConfigured == Model!.BaseUrlCurrent)
@if (Model.BaseUrlConfigured == Model.BaseUrlCurrent)
{
RenderRuleAsSuccess(T.Get("setup.ruleUrl.success"));
}
else
{
RenderRuleAsCritical(T.Get("setup.ruleUrl.failure", new { actual = Model!.BaseUrlCurrent, configured = Model!.BaseUrlConfigured }));
RenderRuleAsCritical(T.Get("setup.ruleUrl.failure", new { actual = Model.BaseUrlCurrent, configured = Model.BaseUrlConfigured }));
}
@if (Model!.EverybodyCanCreateApps)
@if (Model.EverybodyCanCreateApps)
{
RenderRuleAsWarning(T.Get("setup.ruleAppCreation.warningAdmins"));
}
@ -103,7 +93,7 @@
RenderRuleAsWarning(T.Get("setup.ruleAppCreation.warningAll"));
}
@if (Model!.EverybodyCanCreateTeams)
@if (Model.EverybodyCanCreateTeams)
{
RenderRuleAsWarning(T.Get("setup.ruleTeamCreation.warningAdmins"));
}
@ -112,12 +102,12 @@
RenderRuleAsWarning(T.Get("setup.ruleTeamCreation.warningAll"));
}
@if (Model!.IsAssetStoreFtp)
@if (Model.IsAssetStoreFtp)
{
RenderRuleAsWarning(T.Get("setup.ruleFtp.warning"));
}
@if (Model!.IsAssetStoreFile)
@if (Model.IsAssetStoreFile)
{
RenderRuleAsWarning(T.Get("setup.ruleFolder.warning"));
}
@ -128,9 +118,9 @@
<div class="profile-section">
<h2 class="mb-3">@T.Get("setup.createUser.headline")</h2>
@if (Model!.HasExternalLogin)
{
<div>
@if (Model.HasExternalLogin)
{
<div>
<small class="form-text text-muted mt-2 mb-2">@T.Get("setup.createUser.loginHint")</small>
<div class="mt-3">
@ -139,23 +129,23 @@
</a>
</div>
</div>
}
}
@if (Model!.HasExternalLogin && Model!.HasPasswordAuth)
@if (Model.HasExternalLogin && Model.HasPasswordAuth)
{
<div class="profile-separator">
<div class="profile-separator-text">@T.Get("setup.createUser.separator")</div>
</div>
}
@if (Model!.HasPasswordAuth)
@if (Model.HasPasswordAuth)
{
<h3>@T.Get("setup.createUser.headlineCreate")</h3>
@if (!string.IsNullOrWhiteSpace(Model!.ErrorMessage))
@if (!string.IsNullOrWhiteSpace(Model.ErrorMessage))
{
<div class="form-alert form-alert-error">
@Model!.ErrorMessage
@Model.ErrorMessage
</div>
}
@ -163,27 +153,24 @@
@Html.AntiForgeryToken()
<div class="form-group">
<label for="email">@T.Get("common.email")</label>
<label asp-for="Email">@T.Get("common.email")</label>
@{ RenderValidation("Email"); }
<input type="text" class="form-control" name="email" id="email" />
<div error-for="Email"></div>
<input asp-for="Email" type="text" class="form-control" />
</div>
<div class="form-group">
<label for="password">@T.Get("common.password")</label>
@{ RenderValidation("Password"); }
<label asp-for="Password">@T.Get("common.password")</label>
<input type="password" class="form-control" name="password" id="password" />
<div error-for="Password"></div>
<input asp-for="Password" type="password" class="form-control" />
</div>
<div class="form-group">
<label for="passwordConfirm">@T.Get("setup.createUser.confirmPassword")</label>
@{ RenderValidation("PasswordConfirm"); }
<label asp-for="PasswordConfirm">@T.Get("setup.createUser.confirmPassword")</label>
<input type="password" class="form-control" name="passwordConfirm" id="passwordConfirm" />
<div error-for="PasswordConfirm"></div>
<input asp-for="PasswordConfirm" type="password" class="form-control" />
</div>
<div class="form-group mb-0">
@ -192,7 +179,7 @@
</form>
}
@if (!Model!.HasExternalLogin && !Model!.HasPasswordAuth)
@if (!Model.HasExternalLogin && !Model.HasPasswordAuth)
{
<div>
@T.Get("setup.createUser.failure")

13
backend/src/Squidex/Areas/IdentityServer/Views/Test/Success.cshtml

@ -0,0 +1,13 @@
@model Squidex.Areas.IdentityServer.Controllers.Error.ErrorVM
@{
ViewBag.Title = T.Get("login.test.title");
}
<img class="splash-image" src="squid.svg?title=Success&text=Login%20was%20successful&face=happy" />
<h1 class="splash-h1">@T.Get("login.test.headline")</h1>
<p class="splash-text">
<span>@T.Get("login.test.text")</span>
</p>

58
backend/src/Squidex/Areas/IdentityServer/Views/ValidationPageHelper.cs

@ -0,0 +1,58 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.AspNetCore.Razor.TagHelpers;
namespace Squidex.Areas.IdentityServer.Views;
[HtmlTargetElement("div", Attributes = "error-for")]
public class ValidationPageHelper : TagHelper
{
private readonly IHtmlHelper htmlHelper;
[HtmlAttributeName("error-for")]
public ModelExpression For { get; set; }
[ViewContext]
[HtmlAttributeNotBound]
public ViewContext ViewContext { get; set; }
public ValidationPageHelper(IHtmlHelper htmlHelper)
{
this.htmlHelper = htmlHelper;
}
public override void Process(TagHelperContext context, TagHelperOutput output)
{
output.Attributes.Clear();
if (htmlHelper is IViewContextAware viewContextAware)
{
viewContextAware.Contextualize(ViewContext);
}
if (ViewContext.ModelState[For.Name]?.ValidationState != ModelValidationState.Invalid)
{
return;
}
var message = htmlHelper.ValidationMessage(For.Name);
if (message == null)
{
return;
}
output.Content.AppendHtml("<span class=\"errors\">");
output.Content.AppendHtml(message);
output.Content.AppendHtml("</span>");
output.Attributes.Add("class", "errors-container");
}
}

1
backend/src/Squidex/Areas/IdentityServer/Views/_ViewImports.cshtml

@ -2,4 +2,5 @@
@using Squidex.Areas.IdentityServer.Views;
@using Squidex.Infrastructure.Translations;
@addTagHelper *, Squidex
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

4
backend/src/Squidex/Config/MyIdentityOptions.cs

@ -55,7 +55,7 @@ public sealed class MyIdentityOptions
public string OidcResponseType { get; set; }
public string OidcOnSignoutRedirectUrl { get; set; }
public string? OidcOnSignoutRedirectUrl { get; set; }
public string[] OidcScopes { get; set; }
@ -67,6 +67,8 @@ public sealed class MyIdentityOptions
public bool AllowPasswordAuth { get; set; }
public bool AllowCustomDomains { get; set; }
public bool LockAutomatically { get; set; }
public bool NoConsent { get; set; }

39
backend/src/Squidex/Squidex.csproj

@ -34,36 +34,37 @@
<ItemGroup>
<PackageReference Include="AspNet.Security.OAuth.GitHub" Version="8.0.0" />
<PackageReference Include="Google.Cloud.Trace.V2" Version="3.4.0" />
<PackageReference Include="Google.Cloud.Trace.V2" Version="3.5.0" />
<PackageReference Include="GraphQL" Version="7.8.0" />
<PackageReference Include="GraphQL.MicrosoftDI" Version="7.8.0" />
<PackageReference Include="GraphQL.SystemTextJson" Version="7.8.0" />
<PackageReference Include="Meziantou.Analyzer" Version="2.0.146">
<PackageReference Include="Meziantou.Analyzer" Version="2.0.150">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="8.0.3" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.MicrosoftAccount" Version="8.0.3" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="8.0.3" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="8.0.3" />
<PackageReference Include="Microsoft.AspNetCore.SpaServices.Extensions" Version="8.0.3" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="8.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.MicrosoftAccount" Version="8.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="8.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="8.0.4" />
<PackageReference Include="Microsoft.AspNetCore.SpaServices.Extensions" Version="8.0.4" />
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" Version="8.0.0" />
<PackageReference Include="Microsoft.CodeAnalysis.RulesetToEditorconfigConverter" Version="3.3.3" />
<PackageReference Include="Microsoft.Data.Edm" Version="5.8.5" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Options" Version="8.0.2" />
<PackageReference Include="Microsoft.OData.Core" Version="7.20.0" />
<PackageReference Include="MongoDB.Driver" Version="2.24.0" />
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="7.5.1" />
<PackageReference Include="Microsoft.OData.Core" Version="7.21.1" />
<PackageReference Include="MongoDB.Driver" Version="2.25.0" />
<PackageReference Include="MongoDB.Driver.Core.Extensions.OpenTelemetry" Version="1.0.0" />
<PackageReference Include="NetTopologySuite.IO.GeoJSON4STJ" Version="4.0.0" />
<PackageReference Include="NJsonSchema" Version="11.0.0" />
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.2.0" />
<PackageReference Include="NSwag.AspNetCore" Version="14.0.4" />
<PackageReference Include="NSwag.AspNetCore" Version="14.0.7" />
<PackageReference Include="OpenCover" Version="4.7.1221" PrivateAssets="all" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.7.1" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.7.1" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.8.1" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.8.1" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="ReportGenerator" Version="5.2.4" PrivateAssets="all" />
<PackageReference Include="ReportGenerator" Version="5.2.5" PrivateAssets="all" />
<PackageReference Include="Squidex.Assets.Azure" Version="6.6.4" />
<PackageReference Include="Squidex.Assets.GoogleCloud" Version="6.6.4" />
<PackageReference Include="Squidex.Assets.FTP" Version="6.6.4" />
@ -78,12 +79,12 @@
<PackageReference Include="Squidex.Messaging.Subscriptions" Version="6.6.4" />
<PackageReference Include="Squidex.OpenIddict.MongoDb" Version="5.1.0" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="YDotNet" Version="0.3.0" />
<PackageReference Include="YDotNet.Native" Version="0.3.0" />
<PackageReference Include="YDotNet.Server" Version="0.3.0" />
<PackageReference Include="YDotNet.Server.MongoDB" Version="0.3.0" />
<PackageReference Include="YDotNet.Server.Redis" Version="0.3.0" />
<PackageReference Include="YDotNet.Server.WebSockets" Version="0.3.0" />
<PackageReference Include="YDotNet" Version="0.4.0" />
<PackageReference Include="YDotNet.Native" Version="0.4.0" />
<PackageReference Include="YDotNet.Server" Version="0.4.0" />
<PackageReference Include="YDotNet.Server.MongoDB" Version="0.4.0" />
<PackageReference Include="YDotNet.Server.Redis" Version="0.4.0" />
<PackageReference Include="YDotNet.Server.WebSockets" Version="0.4.0" />
</ItemGroup>
<PropertyGroup>

2
backend/src/Squidex/Startup.cs

@ -94,7 +94,7 @@ public sealed class Startup
options.Path = "/api/swagger/v1/swagger.json";
});
if (!app.ApplicationServices.GetRequiredService<IWebHostEnvironment>().IsDevelopment())
if (app.ApplicationServices.GetRequiredService<IWebHostEnvironment>().IsDevelopment())
{
app.UseWhenPath(Constants.PrefixIdentityServer, builder =>
{

3
backend/src/Squidex/appsettings.json

@ -594,6 +594,9 @@
// Set to true to show PII (Personally Identifiable Information) in the logs.
"showPII": true,
// Enable custom domains and oidc settings for teams.
"allowCustomDomains": true,
// Enable password auth. Set this to false if you want to disable local login, leaving only 3rd party login options.
"allowPasswordAuth": true,

8
backend/src/Squidex/wwwroot/editor/squidex-editor.js

File diff suppressed because one or more lines are too long

9
backend/tests/Squidex.Domain.Apps.Core.Tests/Model/Teams/Team.json

@ -5,6 +5,7 @@
"created": "2022-12-05T11:00:23Z",
"createdBy": "subject:63761585e06d5466c71521b3",
"id": "265aaf1e-5dff-4c40-b1e6-1149f652cd30",
"isDeleted": false,
"lastModified": "2022-12-05T11:00:23Z",
"lastModifiedBy": "subject:63761585e06d5466c71521b3",
"name": "My Team",
@ -12,5 +13,13 @@
"owner": "subject:63761585e06d5466c71521b3",
"planId": "Premium"
},
"authScheme": {
"domain": "squidex.io",
"displayName": "Squidex",
"clientId": "CLIENT ID",
"clientSecret": "CLIENT SECRET",
"authority": "Authorty",
"signoutRedirectUrl": "URL"
},
"version": 0
}

15
backend/tests/Squidex.Domain.Apps.Core.Tests/Model/Teams/TeamTests.cs

@ -53,6 +53,21 @@ public class TeamTests
Assert.Same(team_1, team_2);
}
[Fact]
public void Should_update_auth_scheme()
{
var scheme1 = new AuthScheme { Authority = "authority1" };
var scheme2 = new AuthScheme { Authority = "authority1" };
var team_1 = team_0.ChangeAuthScheme(scheme1);
var team_2 = team_1.ChangeAuthScheme(scheme2);
Assert.NotSame(team_0, team_1);
Assert.Equal(scheme1, team_1.AuthScheme);
Assert.Equal(scheme1, team_2.AuthScheme);
Assert.Same(team_1, team_2);
}
[Fact]
public void Should_update_contributors()
{

8
backend/tests/Squidex.Domain.Apps.Core.Tests/Squidex.Domain.Apps.Core.Tests.csproj

@ -14,9 +14,9 @@
<ProjectReference Include="..\..\src\Squidex.Infrastructure\Squidex.Infrastructure.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="FakeItEasy" Version="8.1.0" />
<PackageReference Include="FakeItEasy" Version="8.2.0" />
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Meziantou.Analyzer" Version="2.0.146">
<PackageReference Include="Meziantou.Analyzer" Version="2.0.150">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
@ -27,8 +27,8 @@
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.2.0" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.ValueTuple" Version="4.5.0" />
<PackageReference Include="xunit" Version="2.7.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.7">
<PackageReference Include="xunit" Version="2.8.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>

11
backend/tests/Squidex.Domain.Apps.Entities.Tests/AppProviderTests.cs

@ -99,6 +99,17 @@ public class AppProviderTests : GivenContext
Assert.Equal(Team, actual);
}
[Fact]
public async Task Should_get_team_by_domain_from_index()
{
A.CallTo(() => indexForTeams.GetTeamByAuthDomainAsync("squidex.io", CancellationToken))
.Returns(Team);
var actual = await sut.GetTeamByAuthDomainAsync("squidex.io", CancellationToken);
Assert.Equal(Team, actual);
}
[Fact]
public async Task Should_get_teams_from_index()
{

2
backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/DomainObject/AppDomainObjectTests.cs

@ -502,7 +502,7 @@ public class AppDomainObjectTests : HandlerTestBase<App>
}
[Fact]
public async Task ArchiveApp_should_create_events_and_update_deleted_flag()
public async Task DeleteApp_should_create_events_and_update_deleted_flag()
{
var command = new DeleteApp();

8
backend/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/MigrateFieldNamesCommandMiddlewareTests.cs

@ -23,7 +23,7 @@ public class MigrateFieldNamesCommandMiddlewareTests : GivenContext
}
[Fact]
public async void Should_migrate_synchronize_command()
public async Task Should_migrate_synchronize_command()
{
var command = new SynchronizeSchema
{
@ -51,7 +51,7 @@ public class MigrateFieldNamesCommandMiddlewareTests : GivenContext
}
[Fact]
public async void Should_migrate_synchronize_command_with_null_fields()
public async Task Should_migrate_synchronize_command_with_null_fields()
{
var command = new SynchronizeSchema();
@ -64,7 +64,7 @@ public class MigrateFieldNamesCommandMiddlewareTests : GivenContext
}
[Fact]
public async void Should_migrate_configure_command()
public async Task Should_migrate_configure_command()
{
var command = new ConfigureUIFields
{
@ -92,7 +92,7 @@ public class MigrateFieldNamesCommandMiddlewareTests : GivenContext
}
[Fact]
public async void Should_migrate_configure_command_with_null_fields()
public async Task Should_migrate_configure_command_with_null_fields()
{
var command = new ConfigureUIFields();

10
backend/tests/Squidex.Domain.Apps.Entities.Tests/Squidex.Domain.Apps.Entities.Tests.csproj

@ -22,12 +22,12 @@
<ProjectReference Include="..\Squidex.Infrastructure.Tests\Squidex.Infrastructure.Tests.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="FakeItEasy" Version="8.1.0" />
<PackageReference Include="FakeItEasy" Version="8.2.0" />
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="GraphQL" Version="7.8.0" />
<PackageReference Include="GraphQL.SystemTextJson" Version="7.8.0" />
<PackageReference Include="Lorem.Universal.Net" Version="4.0.80" />
<PackageReference Include="Meziantou.Analyzer" Version="2.0.146">
<PackageReference Include="Meziantou.Analyzer" Version="2.0.150">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
@ -39,9 +39,9 @@
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.Reactive.Linq" Version="6.0.0" />
<PackageReference Include="System.ValueTuple" Version="4.5.0" />
<PackageReference Include="Verify.Xunit" Version="23.5.2" />
<PackageReference Include="xunit" Version="2.7.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.7">
<PackageReference Include="Verify.Xunit" Version="24.1.0" />
<PackageReference Include="xunit" Version="2.8.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>

120
backend/tests/Squidex.Domain.Apps.Entities.Tests/Teams/DomainObject/Guards/GuardTeamTests.cs

@ -5,11 +5,13 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Core.Teams;
using Squidex.Domain.Apps.Core.TestHelpers;
using Squidex.Domain.Apps.Entities.Billing;
using Squidex.Domain.Apps.Entities.Teams.Commands;
using Squidex.Domain.Apps.Entities.Teams.DomainObject.Guards;
using Squidex.Domain.Apps.Entities.TestHelpers;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Validation;
using Squidex.Shared.Users;
@ -21,6 +23,7 @@ public class GuardTeamTests : GivenContext, IClassFixture<TranslationsFixture>
private readonly IBillingPlans billingPlans = A.Fake<IBillingPlans>();
private readonly Plan planBasic = new Plan();
private readonly Plan planFree = new Plan();
private readonly AuthScheme scheme;
public GuardTeamTests()
{
@ -35,6 +38,15 @@ public class GuardTeamTests : GivenContext, IClassFixture<TranslationsFixture>
A.CallTo(() => billingPlans.GetPlan("free"))
.Returns(planFree);
scheme = new AuthScheme
{
Domain = "squidex.io",
DisplayName = "Squidex",
Authority = "https://identity.squidex.io",
ClientId = "clientId",
ClientSecret = "clientSecret"
};
}
[Fact]
@ -79,4 +91,112 @@ public class GuardTeamTests : GivenContext, IClassFixture<TranslationsFixture>
GuardTeam.CanChangePlan(command, billingPlans);
}
[Fact]
public async Task CanUpsertAuth_should_not_throw_exception_if_scheme_is_null()
{
var command = new UpsertAuth();
await GuardTeam.CanUpsertAuth(command, AppProvider, CancellationToken);
}
[Fact]
public async Task CanUpsertAuth_should_throw_exception_if_domain_not_defined()
{
var command = new UpsertAuth { Scheme = scheme with { Domain = null! } };
await ValidationAssert.ThrowsAsync(() => GuardTeam.CanUpsertAuth(command, AppProvider, CancellationToken),
new ValidationError("Domain is required.", "Scheme.Domain"));
}
[Fact]
public async Task CanUpsertAuth_should_throw_exception_if_display_name_not_defined()
{
var command = new UpsertAuth { Scheme = scheme with { DisplayName = null! } };
await ValidationAssert.ThrowsAsync(() => GuardTeam.CanUpsertAuth(command, AppProvider, CancellationToken),
new ValidationError("Display name is required.", "Scheme.DisplayName"));
}
[Fact]
public async Task CanUpsertAuth_should_throw_exception_if_client_id_not_defined()
{
var command = new UpsertAuth { Scheme = scheme with { ClientId = null! } };
await ValidationAssert.ThrowsAsync(() => GuardTeam.CanUpsertAuth(command, AppProvider, CancellationToken),
new ValidationError("Client ID is required.", "Scheme.ClientId"));
}
[Fact]
public async Task CanUpsertAuth_should_throw_exception_if_client_secret_not_defined()
{
var command = new UpsertAuth { Scheme = scheme with { ClientSecret = null! } };
await ValidationAssert.ThrowsAsync(() => GuardTeam.CanUpsertAuth(command, AppProvider, CancellationToken),
new ValidationError("Client Secret is required.", "Scheme.ClientSecret"));
}
[Fact]
public async Task CanUpsertAuth_should_throw_exception_if_authority_not_defined()
{
var command = new UpsertAuth { Scheme = scheme with { Authority = null! } };
await ValidationAssert.ThrowsAsync(() => GuardTeam.CanUpsertAuth(command, AppProvider, CancellationToken),
new ValidationError("Authority is required.", "Scheme.Authority"));
}
[Fact]
public async Task CanUpsertAuth_should_throw_exception_if_authority_not_valid()
{
var command = new UpsertAuth { Scheme = scheme with { Authority = "invalid" } };
await ValidationAssert.ThrowsAsync(() => GuardTeam.CanUpsertAuth(command, AppProvider, CancellationToken),
new ValidationError("Authority is not a valid URL.", "Scheme.Authority"));
}
[Fact]
public async Task CanUpsertAuth_should_throw_exception_if_domain_is_already_taken()
{
A.CallTo(() => AppProvider.GetTeamByAuthDomainAsync("squidex.io", CancellationToken))
.Returns(Team);
var command = new UpsertAuth { Scheme = scheme, TeamId = DomainId.NewGuid() };
await ValidationAssert.ThrowsAsync(() => GuardTeam.CanUpsertAuth(command, AppProvider, CancellationToken),
new ValidationError("Domain is already used for another team."));
}
[Fact]
public async Task CanUpsertAuth_should_not_throw_exception_if_command_is_valid()
{
A.CallTo(() => AppProvider.GetTeamByAuthDomainAsync("squidex.io", CancellationToken))
.Returns(Team);
var command = new UpsertAuth { Scheme = scheme, TeamId = Team.Id };
await GuardTeam.CanUpsertAuth(command, AppProvider, CancellationToken);
}
[Fact]
public async Task CanDelete_should_throw_exception_if_app_is_assigned()
{
A.CallTo(() => AppProvider.GetTeamAppsAsync(TeamId, CancellationToken))
.Returns([App]);
var command = new DeleteTeam { TeamId = Team.Id };
await ValidationAssert.ThrowsAsync(() => GuardTeam.CanDelete(command, AppProvider, CancellationToken),
new ValidationError("Cannot delete team, when apps are assigned."));
}
[Fact]
public async Task CanDelete_should_not_throw_exception_if_no_app_is_assigned()
{
A.CallTo(() => AppProvider.GetTeamAppsAsync(TeamId, CancellationToken))
.Returns([]);
var command = new DeleteTeam { TeamId = Team.Id };
await GuardTeam.CanDelete(command, AppProvider, CancellationToken);
}
}

60
backend/tests/Squidex.Domain.Apps.Entities.Tests/Teams/DomainObject/TeamDomainObjectTests.cs

@ -19,6 +19,7 @@ namespace Squidex.Domain.Apps.Entities.Teams.DomainObject;
public class TeamDomainObjectTests : HandlerTestBase<Team>
{
private readonly IAppProvider appProvider = A.Fake<IAppProvider>();
private readonly IBillingPlans billingPlans = A.Fake<IBillingPlans>();
private readonly IBillingManager billingManager = A.Fake<IBillingManager>();
private readonly IUser user;
@ -27,6 +28,7 @@ public class TeamDomainObjectTests : HandlerTestBase<Team>
private readonly Plan planFree = new Plan { Id = "free" };
private readonly string contributorId = DomainId.NewGuid().ToString();
private readonly string name = "My Team";
private readonly AuthScheme scheme;
private readonly TeamDomainObject sut;
protected override DomainId Id
@ -38,6 +40,9 @@ public class TeamDomainObjectTests : HandlerTestBase<Team>
{
user = UserMocks.User(contributorId);
A.CallTo(() => appProvider.GetTeamByAuthDomainAsync(A<string>._, A<CancellationToken>._))
.Returns(Task.FromResult<Team?>(null));
A.CallTo(() => userResolver.FindByIdOrEmailAsync(contributorId, default))
.Returns(user);
@ -53,8 +58,18 @@ public class TeamDomainObjectTests : HandlerTestBase<Team>
A.CallTo(() => billingManager.MustRedirectToPortalAsync(User.Identifier, A<Team>._, A<string>._, CancellationToken))
.Returns(Task.FromResult<Uri?>(null));
scheme = new AuthScheme
{
Domain = "squidex.io",
DisplayName = "Squidex",
Authority = "https://identity.squidex.io",
ClientId = "clientId",
ClientSecret = "clientSecret"
};
var serviceProvider =
new ServiceCollection()
.AddSingleton(appProvider)
.AddSingleton(billingPlans)
.AddSingleton(billingManager)
.AddSingleton(userResolver)
@ -99,6 +114,31 @@ public class TeamDomainObjectTests : HandlerTestBase<Team>
await VerifySutAsync(actual);
}
[Fact]
public async Task UpsertAuth_should_create_events_and_update_scheme()
{
var command = new UpsertAuth { Scheme = scheme };
await ExecuteCreateAsync();
var actual = await PublishIdempotentAsync(sut, command);
await VerifySutAsync(actual);
}
[Fact]
public async Task UpsertAuth_should_create_events_and_remove_scheme()
{
var command = new UpsertAuth { Scheme = null };
await ExecuteCreateAsync();
await ExecuteUpsertAuthAsync();
var actual = await PublishIdempotentAsync(sut, command);
await VerifySutAsync(actual);
}
[Fact]
public async Task ChangePlan_should_create_events_and_update_plan()
{
@ -234,6 +274,21 @@ public class TeamDomainObjectTests : HandlerTestBase<Team>
await Verify(actual);
}
[Fact]
public async Task DeleteTeam_should_create_events_and_update_deleted_flag()
{
var command = new DeleteTeam();
await ExecuteCreateAsync();
var actual = await PublishAsync(sut, command);
await VerifySutAsync(actual, None.Value);
A.CallTo(() => billingManager.UnsubscribeAsync(command.Actor.Identifier, A<Team>._, default))
.MustHaveHappened();
}
private Task ExecuteCreateAsync()
{
return PublishAsync(sut, new CreateTeam { Name = name, TeamId = TeamId });
@ -249,6 +304,11 @@ public class TeamDomainObjectTests : HandlerTestBase<Team>
return PublishAsync(sut, new ChangePlan { PlanId = planPaid.Id });
}
private Task ExecuteUpsertAuthAsync()
{
return PublishAsync(sut, new UpsertAuth { Scheme = scheme });
}
private async Task VerifySutAsync(object? actual, object? expected = null)
{
if (expected == null)

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save