Browse Source

Refactoring/users (#623)

* Simplified user service.

* Refactor users and deleting of users.

* Fix tests

* Auth fix.

* Should revert fixes.

* Stability improvements.

* Build fix.

* Tests fixed.

* Tests

* Tests simplified.
pull/624/head
Sebastian Stehle 5 years ago
committed by GitHub
parent
commit
532ba9bc9d
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 9
      backend/i18n/frontend_en.json
  2. 9
      backend/i18n/frontend_it.json
  3. 9
      backend/i18n/frontend_nl.json
  4. 2
      backend/i18n/source/backend_en.json
  5. 1
      backend/i18n/source/backend_it.json
  6. 1
      backend/i18n/source/backend_nl.json
  7. 9
      backend/i18n/source/frontend_en.json
  8. 4
      backend/i18n/source/frontend_it.json
  9. 3
      backend/i18n/source/frontend_nl.json
  10. 6
      backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/PredefinedPatternsFormatter.cs
  11. 2
      backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/JintUser.cs
  12. 3
      backend/src/Squidex.Domain.Apps.Core.Operations/Templates/Extensions/UserFluidExtension.cs
  13. 1
      backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/GeoQueryTransformer.cs
  14. 2
      backend/src/Squidex.Domain.Apps.Entities/Context.cs
  15. 4
      backend/src/Squidex.Domain.Apps.Entities/History/NotifoService.cs
  16. 7
      backend/src/Squidex.Domain.Apps.Entities/Notifications/NotificationEmailSender.cs
  17. 60
      backend/src/Squidex.Domain.Users/DefaultUserResolver.cs
  18. 408
      backend/src/Squidex.Domain.Users/DefaultUserService.cs
  19. 26
      backend/src/Squidex.Domain.Users/IUserEventHandler.cs
  20. 16
      backend/src/Squidex.Domain.Users/IUserEvents.cs
  21. 55
      backend/src/Squidex.Domain.Users/IUserService.cs
  22. 49
      backend/src/Squidex.Domain.Users/UserEvents.cs
  23. 247
      backend/src/Squidex.Domain.Users/UserManagerExtensions.cs
  24. 109
      backend/src/Squidex.Domain.Users/UserValues.cs
  25. 20
      backend/src/Squidex.Domain.Users/UserWithClaims.cs
  26. 2
      backend/src/Squidex.Infrastructure/Validation/LocalizedCompareAttribute.cs
  27. 2
      backend/src/Squidex.Infrastructure/Validation/LocalizedEmailAddressAttribute.cs
  28. 2
      backend/src/Squidex.Infrastructure/Validation/LocalizedRangeAttribute.cs
  29. 2
      backend/src/Squidex.Infrastructure/Validation/LocalizedRegularExpressionAttribute.cs
  30. 2
      backend/src/Squidex.Infrastructure/Validation/LocalizedRequiredAttribute.cs
  31. 2
      backend/src/Squidex.Infrastructure/Validation/LocalizedStringLengthAttribute.cs
  32. 47
      backend/src/Squidex.Shared/Identity/ClaimsPrincipalExtensions.cs
  33. 20
      backend/src/Squidex.Shared/Identity/SquidexClaimTypes.cs
  34. 159
      backend/src/Squidex.Shared/Identity/SquidexClaimsExtensions.cs
  35. 6
      backend/src/Squidex.Shared/Texts.it.resx
  36. 6
      backend/src/Squidex.Shared/Texts.nl.resx
  37. 6
      backend/src/Squidex.Shared/Texts.resx
  38. 28
      backend/src/Squidex.Shared/Users/ClientUser.cs
  39. 4
      backend/src/Squidex.Shared/Users/IUser.cs
  40. 121
      backend/src/Squidex.Shared/Users/UserExtensions.cs
  41. 7
      backend/src/Squidex/Areas/Api/Controllers/Apps/AppClientsController.cs
  42. 7
      backend/src/Squidex/Areas/Api/Controllers/Apps/AppContributorsController.cs
  43. 7
      backend/src/Squidex/Areas/Api/Controllers/Apps/AppLanguagesController.cs
  44. 7
      backend/src/Squidex/Areas/Api/Controllers/Apps/AppPatternsController.cs
  45. 9
      backend/src/Squidex/Areas/Api/Controllers/Apps/AppRolesController.cs
  46. 9
      backend/src/Squidex/Areas/Api/Controllers/Apps/AppWorkflowsController.cs
  47. 12
      backend/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs
  48. 3
      backend/src/Squidex/Areas/Api/Controllers/Apps/Models/ContributorDto.cs
  49. 5
      backend/src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs
  50. 7
      backend/src/Squidex/Areas/Api/Controllers/Assets/AssetFoldersController.cs
  51. 14
      backend/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs
  52. 3
      backend/src/Squidex/Areas/Api/Controllers/Backups/BackupContentController.cs
  53. 7
      backend/src/Squidex/Areas/Api/Controllers/Backups/BackupsController.cs
  54. 3
      backend/src/Squidex/Areas/Api/Controllers/Backups/RestoreController.cs
  55. 3
      backend/src/Squidex/Areas/Api/Controllers/Comments/CommentsController.cs
  56. 3
      backend/src/Squidex/Areas/Api/Controllers/Comments/Notifications/UserNotificationsController.cs
  57. 31
      backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs
  58. 9
      backend/src/Squidex/Areas/Api/Controllers/EventConsumers/EventConsumersController.cs
  59. 3
      backend/src/Squidex/Areas/Api/Controllers/History/HistoryController.cs
  60. 3
      backend/src/Squidex/Areas/Api/Controllers/Languages/LanguagesController.cs
  61. 3
      backend/src/Squidex/Areas/Api/Controllers/News/NewsController.cs
  62. 3
      backend/src/Squidex/Areas/Api/Controllers/Ping/PingController.cs
  63. 5
      backend/src/Squidex/Areas/Api/Controllers/Plans/AppPlansController.cs
  64. 17
      backend/src/Squidex/Areas/Api/Controllers/Rules/RulesController.cs
  65. 35
      backend/src/Squidex/Areas/Api/Controllers/Schemas/SchemaFieldsController.cs
  66. 21
      backend/src/Squidex/Areas/Api/Controllers/Schemas/SchemasController.cs
  67. 3
      backend/src/Squidex/Areas/Api/Controllers/Search/SearchController.cs
  68. 9
      backend/src/Squidex/Areas/Api/Controllers/Statistics/UsagesController.cs
  69. 3
      backend/src/Squidex/Areas/Api/Controllers/Translations/TranslationsController.cs
  70. 7
      backend/src/Squidex/Areas/Api/Controllers/UI/UIController.cs
  71. 7
      backend/src/Squidex/Areas/Api/Controllers/Users/Models/UserDto.cs
  72. 4
      backend/src/Squidex/Areas/Api/Controllers/Users/Models/UsersDto.cs
  73. 51
      backend/src/Squidex/Areas/Api/Controllers/Users/UserManagementController.cs
  74. 16
      backend/src/Squidex/Areas/Api/Controllers/Users/UsersController.cs
  75. 24
      backend/src/Squidex/Areas/IdentityServer/Config/CreateAdminInitializer.cs
  76. 57
      backend/src/Squidex/Areas/IdentityServer/Config/IdentityServerExtensions.cs
  77. 3
      backend/src/Squidex/Areas/IdentityServer/Config/IdentityServerServices.cs
  78. 20
      backend/src/Squidex/Areas/IdentityServer/Config/LazyClientStore.cs
  79. 145
      backend/src/Squidex/Areas/IdentityServer/Controllers/Account/AccountController.cs
  80. 103
      backend/src/Squidex/Areas/IdentityServer/Controllers/Profile/ProfileController.cs
  81. 2
      backend/src/Squidex/Areas/IdentityServer/Startup.cs
  82. 4
      backend/src/Squidex/Areas/OrleansDashboard/Middlewares/OrleansDashboardAuthenticationMiddleware.cs
  83. 2
      backend/src/Squidex/Config/Authentication/GithubHandler.cs
  84. 4
      backend/src/Squidex/Config/Authentication/GoogleHandler.cs
  85. 5
      backend/src/Squidex/Config/Authentication/MicrosoftHandler.cs
  86. 2
      backend/src/Squidex/Config/Domain/HistoryServices.cs
  87. 4
      backend/src/Squidex/Config/Domain/SubscriptionServices.cs
  88. 207
      backend/tests/Squidex.Domain.Users.Tests/DefaultUserResolverTests.cs
  89. 607
      backend/tests/Squidex.Domain.Users.Tests/DefaultUserServiceTests.cs
  90. 4
      backend/tools/TestSuite/TestSuite.ApiTests/AppTests.cs
  91. 11
      frontend/app/features/administration/pages/users/user.component.html
  92. 4
      frontend/app/features/administration/pages/users/user.component.ts
  93. 2
      frontend/app/features/administration/pages/users/users-page.component.html
  94. 19
      frontend/app/features/administration/services/users.service.spec.ts
  95. 15
      frontend/app/features/administration/services/users.service.ts
  96. 10
      frontend/app/features/administration/state/users.state.spec.ts
  97. 12
      frontend/app/features/administration/state/users.state.ts
  98. 6
      frontend/app/features/content/pages/content/content-page.component.html
  99. 4
      frontend/app/features/content/pages/content/references/content-references.component.html
  100. 10
      frontend/app/features/schemas/pages/schema/schema-page.component.html

9
backend/i18n/frontend_en.json

@ -352,6 +352,7 @@
"contents.arrayNoFields": "Add a nested field first to add items.",
"contents.assetsUpload": "Drop files or click",
"contents.autotranslate": "Autotranslate from master language",
"contents.bulkFailed": "Failed to delete or update content. Please reload.",
"contents.changeStatusTo": "Change content item(s) to {action}",
"contents.changeStatusToImmediately": "Set to {action} immediately.",
"contents.changeStatusToLater": "Set to {action} at a later point date and time.",
@ -370,7 +371,6 @@
"contents.currentStatusLabel": "Current Version",
"contents.deleteConfirmText": "Do you really want to delete the content?",
"contents.deleteConfirmTitle": "Delete content",
"contents.deleteFailed": "Failed to delete content. Please reload.",
"contents.deleteManyConfirmText": "Do you really want to delete the selected content items?",
"contents.deleteReferrerConfirmText": "The content is referenced by another content item.\n\nDo you really want to delete this content?",
"contents.deleteReferrerConfirmTitle": "Delete content",
@ -379,7 +379,6 @@
"contents.draftNew": "New Draft",
"contents.draftStatus": "New Version",
"contents.editPageTitle": "Edit Content",
"contents.editTitle": "Edit Content",
"contents.invariantFieldDescription": "The '{fieldName}' field of the content item.",
"contents.languageModeAll": "All Languages",
"contents.languageModeSingle": "Single Language",
@ -644,7 +643,6 @@
"rules.wizard.selectAction": "Select Action",
"rules.wizard.selectTrigger": "Select Trigger",
"rules.wizard.triggerHint": "The selection of the trigger type cannot be changed later.",
"schema.fields.localConfirmText": "Lock field",
"schemas.addField": "Add Field",
"schemas.addFieldAndClose": "Create and close",
"schemas.addFieldAndCreate": "Create and add field",
@ -883,17 +881,22 @@
"users.createPageTitle": "Create User",
"users.createTitle": "New User",
"users.createTooltip": "New User (CTRL + N)",
"users.deleteConfirmText": "Do you really want to delete this user?",
"users.deleteConfirmTitle": "Delete user",
"users.deleteFailed": "Failed to delete user. Please reload.",
"users.editPageTitle": "Edit User",
"users.editTitle": "Edit User",
"users.listPageTitle": "User Management",
"users.listTitle": "Users",
"users.loadFailed": "Failed to load users. Please reload.",
"users.loadUserFailed": "Failed to load user. Please reload.",
"users.lockFailed": "Failed to lock user. Please reload.",
"users.lockTooltip": "Lock User",
"users.passwordConfirmValidationMessage": "Passwords must be the same.",
"users.refreshTooltip": "Refresh Users (CTRL + SHIFT + R)",
"users.reloaded": "Users reloaded.",
"users.search": "Search for user",
"users.unlockFailed": "Failed to unlock user. Please reload.",
"users.unlockTooltip": "Unlock User",
"users.updateFailed": "Failed to update user. Please reload.",
"validation.between": "{field} must be between '{min}' and '{max}'.",

9
backend/i18n/frontend_it.json

@ -352,6 +352,7 @@
"contents.arrayNoFields": "Aggiungi un primo campo annidato agli elementi.",
"contents.assetsUpload": "Trascina i file o clicca",
"contents.autotranslate": "Traduci in automatico dalla lingua principale",
"contents.bulkFailed": "Non è stato possibile eliminare il contenuto. Per favore ricarica.",
"contents.changeStatusTo": "Cambia l'elemeto(i) del contenuti in {action}",
"contents.changeStatusToImmediately": "Imposta {action} immediatamente.",
"contents.changeStatusToLater": "Imposta {action} ad una data e ora successiva.",
@ -370,7 +371,6 @@
"contents.currentStatusLabel": "Versione corrente",
"contents.deleteConfirmText": "Sei sicuro di voler eliminare il contenuto?",
"contents.deleteConfirmTitle": "Elimina il contenuto",
"contents.deleteFailed": "Non è stato possibile eliminare il contenuto. Per favore ricarica.",
"contents.deleteManyConfirmText": "Sei sicuro di voler eliminare gli elementi del contenuto selezionati?",
"contents.deleteReferrerConfirmText": "Il contenuto è collegato a un altro contenuto.\n\nSei sicuro di volerlo cancellare?",
"contents.deleteReferrerConfirmTitle": "Contenuto cancellato",
@ -379,7 +379,6 @@
"contents.draftNew": "Nuova bozza",
"contents.draftStatus": "Nuova versione",
"contents.editPageTitle": "Modifica contenuto",
"contents.editTitle": "Modifica il contenuto",
"contents.invariantFieldDescription": "Il campo '{fieldName}' del contenuto.",
"contents.languageModeAll": "Tutte le lingue",
"contents.languageModeSingle": "Una sola lingua",
@ -644,7 +643,6 @@
"rules.wizard.selectAction": "Seleziona l'Azione",
"rules.wizard.selectTrigger": "Seleziona l'Attivazione",
"rules.wizard.triggerHint": "La selezione del tipo di attivazione non potrà essere modificata successivamente.",
"schema.fields.localConfirmText": "Blocca il campo",
"schemas.addField": "Aggiungi un Campo",
"schemas.addFieldAndClose": "Crea e chiudi",
"schemas.addFieldAndCreate": "Crea e aggiungi il campo",
@ -883,17 +881,22 @@
"users.createPageTitle": "Crea un utente",
"users.createTitle": "Nuovo utente",
"users.createTooltip": "Nuovo utente (CTRL + N)",
"users.deleteConfirmText": "Do you really want to delete this user?",
"users.deleteConfirmTitle": "Delete user",
"users.deleteFailed": "Failed to delete user. Please reload.",
"users.editPageTitle": "Modifica l'utente",
"users.editTitle": "Modifica l'utente",
"users.listPageTitle": "Gestione Utente",
"users.listTitle": "Utenti",
"users.loadFailed": "Non è stato possibile caricare gli utenti. Per favore ricarica.",
"users.loadUserFailed": "Non è stato possibile caricare l'utente. Per favore ricarica.",
"users.lockFailed": "Failed to lock user. Please reload.",
"users.lockTooltip": "Utente bloccato",
"users.passwordConfirmValidationMessage": "Le password devono essere uguali.",
"users.refreshTooltip": "Aggiorna gli Utenti (CTRL + SHIFT + R)",
"users.reloaded": "Utenti ricaricati.",
"users.search": "Cerca l'utente",
"users.unlockFailed": "Failed to unlock user. Please reload.",
"users.unlockTooltip": "Sblocca l'utente",
"users.updateFailed": "Non è stato possibile aggiornare l'utente. Per favore ricarica.",
"validation.between": "{field} deve essere tra '{min}' e '{max}'.",

9
backend/i18n/frontend_nl.json

@ -352,6 +352,7 @@
"contents.arrayNoFields": "Voeg eerst een genest veld toe om items toe te voegen.",
"contents.assetsUpload": "Zet bestanden neer of klik",
"contents.autotranslate": "Automatisch vertalen vanuit de hoofdtaal",
"contents.bulkFailed": "Verwijderen van inhoud is mislukt. Laad opnieuw.",
"contents.changeStatusTo": "Verander inhoud item(s) in {action}",
"contents.changeStatusToImmediately": "Zet onmiddellijk op {action}.",
"contents.changeStatusToLater": "Zet op {action} op een latere datum en tijd.",
@ -370,7 +371,6 @@
"contents.currentStatusLabel": "Huidige versie",
"contents.deleteConfirmText": "Wilt je de inhoud echt verwijderen?",
"contents.deleteConfirmTitle": "Inhoud verwijderen",
"contents.deleteFailed": "Verwijderen van inhoud is mislukt. Laad opnieuw.",
"contents.deleteManyConfirmText": "Weet je zeker dat je de geselecteerde inhoudsitems wilt verwijderen?",
"contents.deleteReferrerConfirmText": "Er wordt naar de inhoud verwezen door een ander inhoudsitem. \n \n Wil je de inhoud echt verwijderen?",
"contents.deleteReferrerConfirmTitle": "Inhoud verwijderen",
@ -379,7 +379,6 @@
"contents.draftNew": "Nieuw concept",
"contents.draftStatus": "Nieuwe versie",
"contents.editPageTitle": "Inhoud bewerken",
"contents.editTitle": "Inhoud bewerken",
"contents.invariantFieldDescription": "Het veld '{fieldName}' van het inhoudsitem.",
"contents.languageModeAll": "Alle talen",
"contents.languageModeSingle": "Enkele taal",
@ -644,7 +643,6 @@
"rules.wizard.selectAction": "Selecteer actie",
"rules.wizard.selectTrigger": "Selecteer Trigger",
"rules.wizard.triggerHint": "De selectie van het triggertype kan later niet worden gewijzigd.",
"schema.fields.localConfirmText": "Lock field",
"schemas.addField": "Veld toevoegen",
"schemas.addFieldAndClose": "Maken en sluiten",
"schemas.addFieldAndCreate": "Maak en voeg veld toe",
@ -883,17 +881,22 @@
"users.createPageTitle": "Gebruiker aanmaken",
"users.createTitle": "Nieuwe gebruiker",
"users.createTooltip": "Nieuwe gebruiker (CTRL + N)",
"users.deleteConfirmText": "Do you really want to delete this user?",
"users.deleteConfirmTitle": "Delete user",
"users.deleteFailed": "Failed to delete user. Please reload.",
"users.editPageTitle": "Gebruiker bewerken",
"users.editTitle": "Gebruiker bewerken",
"users.listPageTitle": "Gebruikersbeheer",
"users.listTitle": "Gebruikers",
"users.loadFailed": "Laden van gebruikers mislukt. Laad opnieuw.",
"users.loadUserFailed": "Kan gebruiker niet laden. Laad opnieuw.",
"users.lockFailed": "Failed to lock user. Please reload.",
"users.lockTooltip": "Gebruiker vergrendelen",
"users.passwordConfirmValidationMessage": "Wachtwoorden moeten hetzelfde zijn.",
"users.refreshTooltip": "Ververs gebruikers (CTRL + SHIFT + R)",
"users.reloaded": "Gebruikers herladen.",
"users.search": "Zoeken naar gebruiker",
"users.unlockFailed": "Failed to unlock user. Please reload.",
"users.unlockTooltip": "Gebruiker ontgrendelen",
"users.updateFailed": "Update gebruiker mislukt. Laad opnieuw.",
"validation.between": "{field} moet tussen '{min}' en '{max}' liggen.",

2
backend/i18n/source/backend_en.json

@ -126,7 +126,6 @@
"contents.invalidGeolocation": "Invalid json type, expected latitude/longitude object.",
"contents.invalidGeolocationLatitude": "Latitude must be between -90 and 90.",
"contents.invalidGeolocationLongitude": "Longitude must be between -180 and 180.",
"contents.invalidGeolocationMoreProperties": "Geolocation can only have latitude and longitude property.",
"contents.invalidNumber": "Invalid json type, expected number.",
"contents.invalidString": "Invalid json type, expected string.",
"contents.listReferences": "{count} Reference(s)",
@ -282,6 +281,7 @@
"users.consent.piiHeadline": "Personal Information",
"users.consent.piiText": "I understand and agree that Squidex collects the following private information that are retrieved from external authentication providers such as Google, Microsoft or Github. <ul class=\"personal-information\"> <li> Basic personal information (first name, last name and picture) are provided to all other users so that they can add you to their working space. </li><li> At anytime you have the option to change these information to anonymize your account. </li><li> Your user account has an unique identifier and for all your changes we track, that you made these changes and provide this information to other users. </li></ul>",
"users.consent.title": "Consent",
"users.deleteYourselfError": "You cannot delete yourself.",
"users.error.headline": "Operation failed",
"users.error.text": "We are really sorry that something went wrong.",
"users.error.title": "Error",

1
backend/i18n/source/backend_it.json

@ -126,7 +126,6 @@
"contents.invalidGeolocation": "Errore nel json, atteso un object latitudine/longitudine.",
"contents.invalidGeolocationLatitude": "La latitudine deve essere tra -90 and 90.",
"contents.invalidGeolocationLongitude": "La longitude deve essere tra -180 and 180.",
"contents.invalidGeolocationMoreProperties": "E' possibile impostare la geolocalizzazione solo impostando latitudine e longitudine.",
"contents.invalidNumber": "Errore nel json,, atteso un number.",
"contents.invalidString": "Errore nel json, atteso una string.",
"contents.listReferences": "{count} Collegamenti(s)",

1
backend/i18n/source/backend_nl.json

@ -126,7 +126,6 @@
"contents.invalidGeolocation": "Ongeldig json-type, verwacht object voor lengte- / breedtegraad.",
"contents.invalidGeolocationLatitude": "Breedtegraad moet tussen -90 en 90 zijn.",
"contents.invalidGeolocationLongitude": "Lengtegraad moet tussen -180 en 180 liggen.",
"contents.invalidGeolocationMoreProperties": "Geolocatie kan alleen de eigenschap lengte- en breedtegraad hebben.",
"contents.invalidNumber": "Ongeldig json-type, verwacht aantal.",
"contents.invalidString": "Ongeldig json-type, verwachte string.",
"contents.listReferences": "{count} referentie (s)",

9
backend/i18n/source/frontend_en.json

@ -352,6 +352,7 @@
"contents.arrayNoFields": "Add a nested field first to add items.",
"contents.assetsUpload": "Drop files or click",
"contents.autotranslate": "Autotranslate from master language",
"contents.bulkFailed": "Failed to delete or update content. Please reload.",
"contents.changeStatusTo": "Change content item(s) to {action}",
"contents.changeStatusToImmediately": "Set to {action} immediately.",
"contents.changeStatusToLater": "Set to {action} at a later point date and time.",
@ -370,7 +371,6 @@
"contents.currentStatusLabel": "Current Version",
"contents.deleteConfirmText": "Do you really want to delete the content?",
"contents.deleteConfirmTitle": "Delete content",
"contents.deleteFailed": "Failed to delete content. Please reload.",
"contents.deleteManyConfirmText": "Do you really want to delete the selected content items?",
"contents.deleteReferrerConfirmText": "The content is referenced by another content item.\n\nDo you really want to delete this content?",
"contents.deleteReferrerConfirmTitle": "Delete content",
@ -379,7 +379,6 @@
"contents.draftNew": "New Draft",
"contents.draftStatus": "New Version",
"contents.editPageTitle": "Edit Content",
"contents.editTitle": "Edit Content",
"contents.invariantFieldDescription": "The '{fieldName}' field of the content item.",
"contents.languageModeAll": "All Languages",
"contents.languageModeSingle": "Single Language",
@ -644,7 +643,6 @@
"rules.wizard.selectAction": "Select Action",
"rules.wizard.selectTrigger": "Select Trigger",
"rules.wizard.triggerHint": "The selection of the trigger type cannot be changed later.",
"schema.fields.localConfirmText": "Lock field",
"schemas.addField": "Add Field",
"schemas.addFieldAndClose": "Create and close",
"schemas.addFieldAndCreate": "Create and add field",
@ -883,17 +881,22 @@
"users.createPageTitle": "Create User",
"users.createTitle": "New User",
"users.createTooltip": "New User (CTRL + N)",
"users.deleteConfirmText": "Do you really want to delete this user?",
"users.deleteConfirmTitle": "Delete user",
"users.deleteFailed": "Failed to delete user. Please reload.",
"users.editPageTitle": "Edit User",
"users.editTitle": "Edit User",
"users.listPageTitle": "User Management",
"users.listTitle": "Users",
"users.loadFailed": "Failed to load users. Please reload.",
"users.loadUserFailed": "Failed to load user. Please reload.",
"users.lockFailed": "Failed to lock user. Please reload.",
"users.lockTooltip": "Lock User",
"users.passwordConfirmValidationMessage": "Passwords must be the same.",
"users.refreshTooltip": "Refresh Users (CTRL + SHIFT + R)",
"users.reloaded": "Users reloaded.",
"users.search": "Search for user",
"users.unlockFailed": "Failed to unlock user. Please reload.",
"users.unlockTooltip": "Unlock User",
"users.updateFailed": "Failed to update user. Please reload.",
"validation.between": "{field} must be between '{min}' and '{max}'.",

4
backend/i18n/source/frontend_it.json

@ -347,6 +347,7 @@
"contents.arrayNoFields": "Aggiungi un primo campo annidato agli elementi.",
"contents.assetsUpload": "Trascina i file o clicca",
"contents.autotranslate": "Traduci in automatico dalla lingua principale",
"contents.bulkFailed": "Non è stato possibile eliminare il contenuto. Per favore ricarica.",
"contents.changeStatusTo": "Cambia l'elemeto(i) del contenuti in {action}",
"contents.changeStatusToImmediately": "Imposta {action} immediatamente.",
"contents.changeStatusToLater": "Imposta {action} ad una data e ora successiva.",
@ -362,7 +363,6 @@
"contents.currentStatusLabel": "Versione corrente",
"contents.deleteConfirmText": "Sei sicuro di voler eliminare il contenuto?",
"contents.deleteConfirmTitle": "Elimina il contenuto",
"contents.deleteFailed": "Non è stato possibile eliminare il contenuto. Per favore ricarica.",
"contents.deleteManyConfirmText": "Sei sicuro di voler eliminare gli elementi del contenuto selezionati?",
"contents.deleteReferrerConfirmText": "Il contenuto è collegato a un altro contenuto.\n\nSei sicuro di volerlo cancellare?",
"contents.deleteReferrerConfirmTitle": "Contenuto cancellato",
@ -371,7 +371,6 @@
"contents.draftNew": "Nuova bozza",
"contents.draftStatus": "Nuova versione",
"contents.editPageTitle": "Modifica contenuto",
"contents.editTitle": "Modifica il contenuto",
"contents.invariantFieldDescription": "Il campo '{fieldName}' del contenuto.",
"contents.languageModeAll": "Tutte le lingue",
"contents.languageModeSingle": "Una sola lingua",
@ -626,7 +625,6 @@
"rules.wizard.selectAction": "Seleziona l'Azione",
"rules.wizard.selectTrigger": "Seleziona l'Attivazione",
"rules.wizard.triggerHint": "La selezione del tipo di attivazione non potrà essere modificata successivamente.",
"schema.fields.localConfirmText": "Blocca il campo",
"schemas.addField": "Aggiungi un Campo",
"schemas.addFieldAndClose": "Crea e chiudi",
"schemas.addFieldAndCreate": "Crea e aggiungi il campo",

3
backend/i18n/source/frontend_nl.json

@ -340,6 +340,7 @@
"contents.arrayNoFields": "Voeg eerst een genest veld toe om items toe te voegen.",
"contents.assetsUpload": "Zet bestanden neer of klik",
"contents.autotranslate": "Automatisch vertalen vanuit de hoofdtaal",
"contents.bulkFailed": "Verwijderen van inhoud is mislukt. Laad opnieuw.",
"contents.changeStatusTo": "Verander inhoud item(s) in {action}",
"contents.changeStatusToImmediately": "Zet onmiddellijk op {action}.",
"contents.changeStatusToLater": "Zet op {action} op een latere datum en tijd.",
@ -355,7 +356,6 @@
"contents.currentStatusLabel": "Huidige versie",
"contents.deleteConfirmText": "Wilt je de inhoud echt verwijderen?",
"contents.deleteConfirmTitle": "Inhoud verwijderen",
"contents.deleteFailed": "Verwijderen van inhoud is mislukt. Laad opnieuw.",
"contents.deleteManyConfirmText": "Weet je zeker dat je de geselecteerde inhoudsitems wilt verwijderen?",
"contents.deleteReferrerConfirmText": "Er wordt naar de inhoud verwezen door een ander inhoudsitem. \n \n Wil je de inhoud echt verwijderen?",
"contents.deleteReferrerConfirmTitle": "Inhoud verwijderen",
@ -364,7 +364,6 @@
"contents.draftNew": "Nieuw concept",
"contents.draftStatus": "Nieuwe versie",
"contents.editPageTitle": "Inhoud bewerken",
"contents.editTitle": "Inhoud bewerken",
"contents.invariantFieldDescription": "Het veld '{fieldName}' van het inhoudsitem.",
"contents.languageModeAll": "Alle talen",
"contents.languageModeSingle": "Enkele taal",

6
backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/PredefinedPatternsFormatter.cs

@ -10,7 +10,7 @@ using System.Collections.Generic;
using System.Globalization;
using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
using Squidex.Infrastructure;
using Squidex.Shared.Users;
using Squidex.Shared.Identity;
using Squidex.Text;
namespace Squidex.Domain.Apps.Core.HandleRules
@ -161,7 +161,7 @@ namespace Squidex.Domain.Apps.Core.HandleRules
{
if (@event is EnrichedUserEventBase userEvent)
{
return userEvent.User?.DisplayName();
return userEvent.User?.Claims.DisplayName();
}
return null;
@ -191,7 +191,7 @@ namespace Squidex.Domain.Apps.Core.HandleRules
{
if (@event is EnrichedCommentEvent commentEvent)
{
return commentEvent.MentionedUser.DisplayName();
return commentEvent.MentionedUser.Claims.DisplayName();
}
return null;

2
backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/JintUser.cs

@ -26,7 +26,7 @@ namespace Squidex.Domain.Apps.Core.Scripting
var isClient = !string.IsNullOrWhiteSpace(clientId);
return CreateUser(engine, user.Id, isClient, user.Email, user.DisplayName(), user.Claims);
return CreateUser(engine, user.Id, isClient, user.Email, user.Claims.DisplayName(), user.Claims);
}
public static ObjectWrapper Create(Engine engine, ClaimsPrincipal principal)

3
backend/src/Squidex.Domain.Apps.Core.Operations/Templates/Extensions/UserFluidExtension.cs

@ -9,6 +9,7 @@ using System;
using System.Linq;
using Fluid;
using Fluid.Values;
using Squidex.Shared.Identity;
using Squidex.Shared.Users;
namespace Squidex.Domain.Apps.Core.Templates.Extensions
@ -26,7 +27,7 @@ namespace Squidex.Domain.Apps.Core.Templates.Extensions
case "email":
return new StringValue(user.Email);
case "name":
return new StringValue(user.DisplayName());
return new StringValue(user.Claims.DisplayName());
default:
{
var claim = user.Claims.FirstOrDefault(x => string.Equals(name, x.Type, StringComparison.OrdinalIgnoreCase));

1
backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/GeoQueryTransformer.cs

@ -5,7 +5,6 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Squidex.Domain.Apps.Entities.Contents.Text;

2
backend/src/Squidex.Domain.Apps.Entities/Context.cs

@ -36,7 +36,7 @@ namespace Squidex.Domain.Apps.Entities
User = user;
Permissions = User.Permissions();
Permissions = User.Claims.Permissions();
IsFrontendClient = User.IsInClient(DefaultClients.Frontend);
}

4
backend/src/Squidex.Domain.Apps.Entities/History/NotifoService.cs

@ -25,7 +25,7 @@ using Squidex.Shared.Users;
namespace Squidex.Domain.Apps.Entities.History
{
public class NotifoService : IUserEventHandler
public class NotifoService : IUserEvents
{
private static readonly Duration MaxAge = Duration.FromHours(12);
private readonly NotifoOptions options;
@ -93,7 +93,7 @@ namespace Squidex.Domain.Apps.Entities.History
var userRequest = new UpsertUserDto
{
Id = user.Id,
FullName = user.DisplayName(),
FullName = user.Claims.DisplayName(),
PreferredLanguage = "en",
PreferredTimezone = null,
Settings = settings,

7
backend/src/Squidex.Domain.Apps.Entities/Notifications/NotificationEmailSender.cs

@ -12,6 +12,7 @@ using Squidex.Domain.Apps.Core;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Email;
using Squidex.Log;
using Squidex.Shared.Identity;
using Squidex.Shared.Users;
namespace Squidex.Domain.Apps.Entities.Notifications
@ -86,7 +87,7 @@ namespace Squidex.Domain.Apps.Entities.Notifications
var vars = new TemplatesVars { Assigner = assigner, AppName = appName };
if (user.HasConsent())
if (user.Claims.HasConsent())
{
return SendEmailAsync("ExistingUser",
texts.ExistingUserSubject,
@ -152,13 +153,13 @@ namespace Squidex.Domain.Apps.Entities.Notifications
if (vars.Assigner != null)
{
text = text.Replace("$ASSIGNER_EMAIL", vars.Assigner.Email);
text = text.Replace("$ASSIGNER_NAME", vars.Assigner.DisplayName());
text = text.Replace("$ASSIGNER_NAME", vars.Assigner.Claims.DisplayName());
}
if (vars.User != null)
{
text = text.Replace("$USER_EMAIL", vars.User.Email);
text = text.Replace("$USER_NAME", vars.User.DisplayName());
text = text.Replace("$USER_NAME", vars.User.Claims.DisplayName());
}
if (vars.ApiCallsLimit != null)

60
backend/src/Squidex.Domain.Users/DefaultUserResolver.cs

@ -10,7 +10,6 @@ using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection;
using Squidex.Infrastructure;
using Squidex.Shared.Users;
@ -34,35 +33,26 @@ namespace Squidex.Domain.Users
{
Guard.NotNullOrEmpty(email, nameof(email));
var created = false;
using (var scope = serviceProvider.CreateScope())
{
var userFactory = scope.ServiceProvider.GetRequiredService<IUserFactory>();
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<IdentityUser>>();
var userService = scope.ServiceProvider.GetRequiredService<IUserService>();
try
{
var user = userFactory.Create(email);
var result = await userManager.CreateAsync(user);
if (result.Succeeded)
var user = await userService.CreateAsync(email, new UserValues
{
created = true;
Invited = invited
});
var values = new UserValues { DisplayName = email, Invited = invited };
await userManager.UpdateAsync(user, values);
}
return (user, true);
}
catch
{
}
var found = await userManager.FindByEmailWithClaimsAsync(email);
var found = await FindByIdOrEmailAsync(email);
return (found, created);
return (found, false);
}
}
@ -74,7 +64,7 @@ namespace Squidex.Domain.Users
using (var scope = serviceProvider.CreateScope())
{
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<IdentityUser>>();
var userService = scope.ServiceProvider.GetRequiredService<IUserService>();
var values = new UserValues
{
@ -84,7 +74,7 @@ namespace Squidex.Domain.Users
}
};
await userManager.UpdateAsync(id, values);
await userService.UpdateAsync(id, values);
}
}
@ -94,9 +84,9 @@ namespace Squidex.Domain.Users
using (var scope = serviceProvider.CreateScope())
{
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<IdentityUser>>();
var userService = scope.ServiceProvider.GetRequiredService<IUserService>();
return await userManager.FindByIdWithClaimsAsync(id);
return await userService.FindByIdAsync(id);
}
}
@ -106,16 +96,15 @@ namespace Squidex.Domain.Users
using (var scope = serviceProvider.CreateScope())
{
var userFactory = scope.ServiceProvider.GetRequiredService<IUserFactory>();
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<IdentityUser>>();
var userService = scope.ServiceProvider.GetRequiredService<IUserService>();
if (userFactory.IsId(idOrEmail))
if (idOrEmail.Contains("@"))
{
return await userManager.FindByIdWithClaimsAsync(idOrEmail);
return await userService.FindByEmailAsync(idOrEmail);
}
else
{
return await userManager.FindByEmailWithClaimsAsync(idOrEmail);
return await userService.FindByIdAsync(idOrEmail);
}
}
}
@ -124,11 +113,11 @@ namespace Squidex.Domain.Users
{
using (var scope = serviceProvider.CreateScope())
{
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<IdentityUser>>();
var userService = scope.ServiceProvider.GetRequiredService<IUserService>();
var result = await userManager.QueryByEmailAsync(null);
var result = await userService.QueryAsync(take: int.MaxValue);
return result.OfType<IUser>().ToList();
return result.ToList();
}
}
@ -138,11 +127,11 @@ namespace Squidex.Domain.Users
using (var scope = serviceProvider.CreateScope())
{
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<IdentityUser>>();
var userService = scope.ServiceProvider.GetRequiredService<IUserService>();
var result = await userManager.QueryByEmailAsync(email);
var result = await userService.QueryAsync(email);
return result.OfType<IUser>().ToList();
return result.ToList();
}
}
@ -152,12 +141,9 @@ namespace Squidex.Domain.Users
using (var scope = serviceProvider.CreateScope())
{
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<IdentityUser>>();
var userFactory = scope.ServiceProvider.GetRequiredService<IUserFactory>();
ids = ids.Where(x => userFactory.IsId(x)).ToArray();
var userService = scope.ServiceProvider.GetRequiredService<IUserService>();
var result = await userManager.QueryByIdsAync(ids);
var result = await userService.QueryAsync(ids);
return result.OfType<IUser>().ToDictionary(x => x.Id);
}

408
backend/src/Squidex.Domain.Users/DefaultUserService.cs

@ -0,0 +1,408 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Identity;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Security;
using Squidex.Log;
using Squidex.Shared;
using Squidex.Shared.Identity;
using Squidex.Shared.Users;
namespace Squidex.Domain.Users
{
public sealed class DefaultUserService : IUserService
{
private readonly UserManager<IdentityUser> userManager;
private readonly IUserFactory userFactory;
private readonly IEnumerable<IUserEvents> userEvents;
private readonly ISemanticLog log;
public DefaultUserService(UserManager<IdentityUser> userManager, IUserFactory userFactory,
IEnumerable<IUserEvents> userEvents, ISemanticLog log)
{
Guard.NotNull(userManager, nameof(userManager));
Guard.NotNull(userFactory, nameof(userFactory));
Guard.NotNull(userEvents, nameof(userEvents));
Guard.NotNull(log, nameof(log));
this.userManager = userManager;
this.userFactory = userFactory;
this.userEvents = userEvents;
this.log = log;
}
public async Task<bool> IsEmptyAsync()
{
var result = await QueryAsync(null, 0, 0);
return result.Total == 0;
}
public string GetUserId(ClaimsPrincipal user)
{
Guard.NotNull(user, nameof(user));
return userManager.GetUserId(user);
}
public async Task<IResultList<IUser>> QueryAsync(IEnumerable<string> ids)
{
Guard.NotNull(ids, nameof(ids));
ids = ids.Where(userFactory.IsId);
if (!ids.Any())
{
return ResultList.CreateFrom<IUser>(0);
}
var users = userManager.Users.Where(x => ids.Contains(x.Id)).ToList();
var resolved = await ResolveAsync(users);
return ResultList.Create(users.Count, resolved);
}
public async Task<IResultList<IUser>> QueryAsync(string? query, int take, int skip)
{
IQueryable<IdentityUser> QueryUsers(string? email = null)
{
var result = userManager.Users;
if (!string.IsNullOrWhiteSpace(email))
{
var normalizedEmail = userManager.NormalizeEmail(email);
result = result.Where(x => x.NormalizedEmail.Contains(normalizedEmail));
}
return result;
}
var userItems = QueryUsers(query).Take(take).Skip(skip).ToList();
var userTotal = QueryUsers(query).LongCount();
var resolved = await ResolveAsync(userItems);
return ResultList.Create(userTotal, resolved);
}
public Task<IList<UserLoginInfo>> GetLoginsAsync(IUser user)
{
Guard.NotNull(user, nameof(user));
return userManager.GetLoginsAsync((IdentityUser)user.Identity);
}
public Task<bool> HasPasswordAsync(IUser user)
{
Guard.NotNull(user, nameof(user));
return userManager.HasPasswordAsync((IdentityUser)user.Identity);
}
public async Task<IUser?> FindByLoginAsync(string provider, string key)
{
Guard.NotNullOrEmpty(provider, nameof(provider));
var user = await userManager.FindByLoginAsync(provider, key);
return await ResolveOptionalAsync(user);
}
public async Task<IUser?> FindByEmailAsync(string email)
{
Guard.NotNullOrEmpty(email, nameof(email));
var user = await userManager.FindByEmailAsync(email);
return await ResolveOptionalAsync(user);
}
public async Task<IUser?> GetAsync(ClaimsPrincipal principal)
{
Guard.NotNull(principal, nameof(principal));
var user = await userManager.GetUserAsync(principal);
return await ResolveOptionalAsync(user);
}
public async Task<IUser?> FindByIdAsync(string id)
{
if (!userFactory.IsId(id))
{
return null;
}
var user = await userManager.FindByIdAsync(id);
return await ResolveOptionalAsync(user);
}
public async Task<IUser> CreateAsync(string email, UserValues? values = null, bool lockAutomatically = false)
{
Guard.NotNullOrEmpty(email, nameof(email));
var isFirst = !userManager.Users.Any();
var user = userFactory.Create(email);
try
{
await userManager.CreateAsync(user).Throw(log);
values ??= new UserValues();
if (string.IsNullOrWhiteSpace(values.DisplayName))
{
values.DisplayName = email;
}
if (isFirst)
{
var permissions = values.Permissions?.ToIds().ToList() ?? new List<string>();
permissions.Add(Permissions.Admin);
values.Permissions = new PermissionSet(permissions);
}
await userManager.SyncClaims(user, values).Throw(log);
if (!string.IsNullOrWhiteSpace(values.Password))
{
await userManager.AddPasswordAsync(user, values.Password).Throw(log);
}
if (!isFirst && lockAutomatically)
{
await userManager.SetLockoutEndDateAsync(user, LockoutDate()).Throw(log);
}
}
catch (Exception)
{
try
{
if (userFactory.IsId(user.Id))
{
await userManager.DeleteAsync(user);
}
}
catch (Exception ex2)
{
log.LogError(ex2, w => w
.WriteProperty("action", "CleanupUser")
.WriteProperty("status", "Failed"));
}
throw;
}
var resolved = await ResolveAsync(user);
foreach (var @events in userEvents)
{
@events.OnUserRegistered(resolved);
}
if (HasConsentGiven(values, null!))
{
foreach (var @events in userEvents)
{
@events.OnConsentGiven(resolved);
}
}
return resolved;
}
public Task<IUser> SetPasswordAsync(string id, string password, string? oldPassword)
{
Guard.NotNullOrEmpty(id, nameof(id));
return ForUserAsync(id, async user =>
{
if (await userManager.HasPasswordAsync(user))
{
await userManager.ChangePasswordAsync(user, oldPassword!, password).Throw(log);
}
else
{
await userManager.AddPasswordAsync(user, password).Throw(log);
}
});
}
public async Task<IUser> UpdateAsync(string id, UserValues values)
{
Guard.NotNullOrEmpty(id, nameof(id));
Guard.NotNull(values, nameof(values));
var user = await GetUserAsync(id);
var oldUser = await ResolveAsync(user);
if (!string.IsNullOrWhiteSpace(values.Email) && values.Email != user.Email)
{
await userManager.SetEmailAsync(user, values.Email).Throw(log);
await userManager.SetUserNameAsync(user, values.Email).Throw(log);
}
await userManager.SyncClaims(user, values).Throw(log);
if (!string.IsNullOrWhiteSpace(values.Password))
{
if (await userManager.HasPasswordAsync(user))
{
await userManager.RemovePasswordAsync(user).Throw(log);
}
await userManager.AddPasswordAsync(user, values.Password).Throw(log);
}
var resolved = await ResolveAsync(user);
foreach (var @events in userEvents)
{
@events.OnUserUpdated(resolved);
}
if (HasConsentGiven(values, oldUser))
{
foreach (var @events in userEvents)
{
@events.OnConsentGiven(resolved);
}
}
return resolved;
}
public Task<IUser> LockAsync(string id)
{
Guard.NotNullOrEmpty(id, nameof(id));
return ForUserAsync(id, user => userManager.SetLockoutEndDateAsync(user, LockoutDate()).Throw(log));
}
public Task<IUser> UnlockAsync(string id)
{
Guard.NotNullOrEmpty(id, nameof(id));
return ForUserAsync(id, user => userManager.SetLockoutEndDateAsync(user, null).Throw(log));
}
public Task<IUser> AddLoginAsync(string id, ExternalLoginInfo externalLogin)
{
Guard.NotNullOrEmpty(id, nameof(id));
return ForUserAsync(id, user => userManager.AddLoginAsync(user, externalLogin).Throw(log));
}
public Task<IUser> RemoveLoginAsync(string id, string loginProvider, string providerKey)
{
Guard.NotNullOrEmpty(id, nameof(id));
return ForUserAsync(id, user => userManager.RemoveLoginAsync(user, loginProvider, providerKey).Throw(log));
}
public async Task DeleteAsync(string id)
{
Guard.NotNullOrEmpty(id, nameof(id));
var user = await GetUserAsync(id);
var resolved = await ResolveAsync(user);
await userManager.DeleteAsync(user).Throw(log);
foreach (var @events in userEvents)
{
@events.OnUserDeleted(resolved);
}
}
private async Task<IUser> ForUserAsync(string id, Func<IdentityUser, Task> action)
{
var user = await GetUserAsync(id);
await action(user);
return await ResolveAsync(user);
}
private async Task<IdentityUser> GetUserAsync(string id)
{
if (!userFactory.IsId(id))
{
throw new DomainObjectNotFoundException(id);
}
var user = await userManager.FindByIdAsync(id);
if (user == null)
{
throw new DomainObjectNotFoundException(id);
}
return user;
}
private Task<IUser[]> ResolveAsync(IEnumerable<IdentityUser> users)
{
return Task.WhenAll(users.Select(async user =>
{
return await ResolveAsync(user);
}));
}
private async Task<IUser> ResolveAsync(IdentityUser user)
{
var claims = await userManager.GetClaimsAsync(user);
if (!claims.Any(x => string.Equals(x.Type, SquidexClaimTypes.DisplayName, StringComparison.OrdinalIgnoreCase)))
{
claims.Add(new Claim(SquidexClaimTypes.DisplayName, user.Email));
}
return new UserWithClaims(user, claims.ToList());
}
private async Task<IUser?> ResolveOptionalAsync(IdentityUser? user)
{
if (user == null)
{
return null;
}
return await ResolveAsync(user);
}
private static bool HasConsentGiven(UserValues values, IUser? oldUser)
{
if (values.Consent == true && oldUser?.Claims.HasConsent() != true)
{
return true;
}
return values.ConsentForEmails == true && oldUser?.Claims.HasConsentForEmails() != true;
}
private static DateTimeOffset LockoutDate()
{
return DateTimeOffset.UtcNow.AddYears(100);
}
}
}

26
backend/src/Squidex.Domain.Users/IUserEventHandler.cs

@ -1,26 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Shared.Users;
namespace Squidex.Domain.Users
{
public interface IUserEventHandler
{
void OnUserRegistered(IUser user)
{
}
void OnUserUpdated(IUser user)
{
}
void OnConsentGiven(IUser user)
{
}
}
}

16
backend/src/Squidex.Domain.Users/IUserEvents.cs

@ -11,10 +11,20 @@ namespace Squidex.Domain.Users
{
public interface IUserEvents
{
void OnUserRegistered(IUser user);
void OnUserRegistered(IUser user)
{
}
void OnUserUpdated(IUser user);
void OnUserUpdated(IUser user)
{
}
void OnConsentGiven(IUser user);
void OnUserDeleted(IUser user)
{
}
void OnConsentGiven(IUser user)
{
}
}
}

55
backend/src/Squidex.Domain.Users/IUserService.cs

@ -0,0 +1,55 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Collections.Generic;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Identity;
using Squidex.Infrastructure;
using Squidex.Shared.Users;
namespace Squidex.Domain.Users
{
public interface IUserService
{
Task<IResultList<IUser>> QueryAsync(IEnumerable<string> ids);
Task<IResultList<IUser>> QueryAsync(string? query = null, int take = 10, int skip = 0);
string GetUserId(ClaimsPrincipal user);
Task<IList<UserLoginInfo>> GetLoginsAsync(IUser user);
Task<bool> HasPasswordAsync(IUser user);
Task<bool> IsEmptyAsync();
Task<IUser> CreateAsync(string email, UserValues? values = null, bool lockAutomatically = false);
Task<IUser?> GetAsync(ClaimsPrincipal principal);
Task<IUser?> FindByEmailAsync(string email);
Task<IUser?> FindByIdAsync(string id);
Task<IUser?> FindByLoginAsync(string provider, string key);
Task<IUser> SetPasswordAsync(string id, string password, string? oldPassword = null);
Task<IUser> AddLoginAsync(string id, ExternalLoginInfo externalLogin);
Task<IUser> RemoveLoginAsync(string id, string loginProvider, string providerKey);
Task<IUser> LockAsync(string id);
Task<IUser> UnlockAsync(string id);
Task<IUser> UpdateAsync(string id, UserValues values);
Task DeleteAsync(string id);
}
}

49
backend/src/Squidex.Domain.Users/UserEvents.cs

@ -1,49 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Collections.Generic;
using Squidex.Infrastructure;
using Squidex.Shared.Users;
namespace Squidex.Domain.Users
{
public sealed class UserEvents : IUserEvents
{
private readonly IEnumerable<IUserEventHandler> userEventHandlers;
public UserEvents(IEnumerable<IUserEventHandler> userEventHandlers)
{
Guard.NotNull(userEventHandlers, nameof(userEventHandlers));
this.userEventHandlers = userEventHandlers;
}
public void OnUserRegistered(IUser user)
{
foreach (var handler in userEventHandlers)
{
handler.OnUserRegistered(user);
}
}
public void OnUserUpdated(IUser user)
{
foreach (var handler in userEventHandlers)
{
handler.OnUserUpdated(user);
}
}
public void OnConsentGiven(IUser user)
{
foreach (var handler in userEventHandlers)
{
handler.OnConsentGiven(user);
}
}
}
}

247
backend/src/Squidex.Domain.Users/UserManagerExtensions.cs

@ -9,263 +9,158 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Text;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Identity;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Translations;
using Squidex.Infrastructure.Validation;
using Squidex.Log;
using Squidex.Shared.Identity;
namespace Squidex.Domain.Users
{
public static class UserManagerExtensions
internal static class UserManagerExtensions
{
public static async Task<UserWithClaims?> GetUserWithClaimsAsync(this UserManager<IdentityUser> userManager, ClaimsPrincipal principal)
public static async Task Throw(this Task<IdentityResult> task, ISemanticLog log)
{
if (principal == null)
{
return null;
}
var user = await userManager.FindByIdWithClaimsAsync(userManager.GetUserId(principal));
var result = await task;
return user;
}
public static async Task<UserWithClaims?> ResolveUserAsync(this UserManager<IdentityUser> userManager, IdentityUser user)
static string Localize(IdentityError error)
{
if (user == null)
if (!string.IsNullOrWhiteSpace(error.Code))
{
return null;
}
var claims = await userManager.GetClaimsAsync(user);
return new UserWithClaims(user, claims);
return T.Get($"dotnet_identity_{error.Code}", error.Description);
}
public static async Task<UserWithClaims?> FindByIdWithClaimsAsync(this UserManager<IdentityUser> userManager, string id)
{
if (id == null)
else
{
return null;
return error.Description;
}
var user = await userManager.FindByIdAsync(id);
return await userManager.ResolveUserAsync(user);
}
public static async Task<UserWithClaims?> FindByEmailWithClaimsAsync(this UserManager<IdentityUser> userManager, string email)
{
if (email == null)
if (!result.Succeeded)
{
return null;
}
var errorMessageBuilder = new StringBuilder();
var user = await userManager.FindByEmailAsync(email);
return await userManager.ResolveUserAsync(user);
}
public static async Task<UserWithClaims?> FindByLoginWithClaimsAsync(this UserManager<IdentityUser> userManager, string loginProvider, string providerKey)
{
if (loginProvider == null || providerKey == null)
foreach (var error in result.Errors)
{
return null;
errorMessageBuilder.Append(error.Code);
errorMessageBuilder.Append(": ");
errorMessageBuilder.AppendLine(error.Description);
}
var user = await userManager.FindByLoginAsync(loginProvider, providerKey);
var errorMessage = errorMessageBuilder.ToString();
return await userManager.ResolveUserAsync(user);
}
public static Task<long> CountByEmailAsync(this UserManager<IdentityUser> userManager, string? email = null)
{
var count = QueryUsers(userManager, email).LongCount();
log.LogError(errorMessage, (ctx, w) => w
.WriteProperty("action", "IdentityOperation")
.WriteProperty("status", "Failed")
.WriteProperty("message", ctx));
return Task.FromResult(count);
throw new ValidationException(result.Errors.Select(x => new ValidationError(Localize(x))).ToList());
}
public static async Task<List<UserWithClaims>> QueryByIdsAync(this UserManager<IdentityUser> userManager, string[] ids)
{
var users = userManager.Users.Where(x => ids.Contains(x.Id)).ToList();
var result = await userManager.ResolveUsersAsync(users);
return result.ToList();
}
public static async Task<List<UserWithClaims>> QueryByEmailAsync(this UserManager<IdentityUser> userManager, string? email = null, int take = 10, int skip = 0)
public static async Task<IdentityResult> SyncClaims(this UserManager<IdentityUser> userManager, IdentityUser user, UserValues values)
{
var users = QueryUsers(userManager, email).Skip(skip).Take(take).ToList();
var result = await userManager.ResolveUsersAsync(users);
var current = await userManager.GetClaimsAsync(user);
return result.ToList();
}
var claimsToRemove = new List<Claim>();
var claimsToAdd = new List<Claim>();
public static Task<UserWithClaims[]> ResolveUsersAsync(this UserManager<IdentityUser> userManager, IEnumerable<IdentityUser> users)
{
return Task.WhenAll(users.Select(async user =>
void RemoveClaims(Func<Claim, bool> predicate)
{
return (await userManager.ResolveUserAsync(user))!;
}));
claimsToAdd.RemoveAll(x => predicate(x));
claimsToRemove.AddRange(current.Where(predicate));
}
public static IQueryable<IdentityUser> QueryUsers(UserManager<IdentityUser> userManager, string? email = null)
void AddClaim(string type, string value)
{
var result = userManager.Users;
if (!string.IsNullOrWhiteSpace(email))
{
var normalizedEmail = userManager.NormalizeEmail(email);
result = result.Where(x => x.NormalizedEmail.Contains(normalizedEmail));
claimsToAdd.Add(new Claim(type, value));
}
return result;
}
public static async Task<UserWithClaims> CreateAsync(this UserManager<IdentityUser> userManager, IUserFactory factory, UserValues values)
void SyncString(string type, string? value)
{
var user = factory.Create(values.Email);
try
if (value != null)
{
await DoChecked(() => userManager.CreateAsync(user));
await DoChecked(() => values.SyncClaims(userManager, user));
RemoveClaims(x => x.Type == type);
if (!string.IsNullOrWhiteSpace(values.Password))
{
await DoChecked(() => userManager.AddPasswordAsync(user, values.Password));
}
}
catch
if (!string.IsNullOrWhiteSpace(value))
{
await userManager.DeleteAsync(user);
throw;
AddClaim(type, value);
}
return (await userManager.ResolveUserAsync(user))!;
}
public static async Task<UserWithClaims> UpdateAsync(this UserManager<IdentityUser> userManager, string id, UserValues values)
{
var user = await userManager.FindByIdAsync(id);
if (user == null)
{
throw new DomainObjectNotFoundException(id);
}
await UpdateAsync(userManager, user, values);
return (await userManager.ResolveUserAsync(user))!;
}
public static Task<IdentityResult> GenerateClientSecretAsync(this UserManager<IdentityUser> userManager, IdentityUser user)
void SyncBoolean(string type, bool? value)
{
var update = new UserValues
if (value != null)
{
ClientSecret = RandomHash.New()
};
RemoveClaims(x => x.Type == type);
return update.SyncClaims(userManager, user);
}
public static async Task<IdentityResult> UpdateSafeAsync(this UserManager<IdentityUser> userManager, IdentityUser user, UserValues values)
{
try
if (value == true)
{
await userManager.UpdateAsync(user, values);
return IdentityResult.Success;
AddClaim(type, value.ToString()!);
}
catch (ValidationException ex)
{
return IdentityResult.Failed(ex.Errors.Select(x => new IdentityError { Description = x.Message }).ToArray());
}
}
public static async Task UpdateAsync(this UserManager<IdentityUser> userManager, IdentityUser user, UserValues values)
{
Guard.NotNull(user, nameof(user));
Guard.NotNull(values, nameof(values));
SyncString(SquidexClaimTypes.ClientSecret, values.ClientSecret);
SyncString(SquidexClaimTypes.DisplayName, values.DisplayName);
SyncString(SquidexClaimTypes.PictureUrl, values.PictureUrl);
if (!string.IsNullOrWhiteSpace(values.Email) && values.Email != user.Email)
{
await DoChecked(() => userManager.SetEmailAsync(user, values.Email));
await DoChecked(() => userManager.SetUserNameAsync(user, values.Email));
}
SyncBoolean(SquidexClaimTypes.Hidden, values.Hidden);
SyncBoolean(SquidexClaimTypes.Invited, values.Invited);
SyncBoolean(SquidexClaimTypes.Consent, values.Consent);
SyncBoolean(SquidexClaimTypes.ConsentForEmails, values.ConsentForEmails);
await DoChecked(() => values.SyncClaims(userManager, user));
if (values.Permissions != null)
{
RemoveClaims(x => x.Type == SquidexClaimTypes.Permissions);
if (!string.IsNullOrWhiteSpace(values.Password))
foreach (var permission in values.Permissions)
{
await DoChecked(() => userManager.RemovePasswordAsync(user));
await DoChecked(() => userManager.AddPasswordAsync(user, values.Password));
AddClaim(SquidexClaimTypes.Permissions, permission.Id);
}
}
public static async Task<UserWithClaims> LockAsync(this UserManager<IdentityUser> userManager, string id)
if (values.Properties != null)
{
var user = await userManager.FindByIdAsync(id);
RemoveClaims(x => x.Type.StartsWith(SquidexClaimTypes.CustomPrefix, StringComparison.OrdinalIgnoreCase));
if (user == null)
foreach (var (name, value) in values.Properties)
{
throw new DomainObjectNotFoundException(id);
AddClaim($"{SquidexClaimTypes.CustomPrefix}:{name}", value);
}
await DoChecked(() => userManager.SetLockoutEndDateAsync(user, DateTimeOffset.UtcNow.AddYears(100)));
return (await userManager.ResolveUserAsync(user))!;
}
public static async Task<UserWithClaims> UnlockAsync(this UserManager<IdentityUser> userManager, string id)
if (values.CustomClaims != null)
{
foreach (var group in values.CustomClaims.GroupBy(x => x.Type))
{
var user = await userManager.FindByIdAsync(id);
RemoveClaims(x => x.Type == group.Key);
if (user == null)
foreach (var claim in group)
{
throw new DomainObjectNotFoundException(id);
AddClaim(claim.Type, claim.Value);
}
}
await DoChecked(() => userManager.SetLockoutEndDateAsync(user, null));
return (await userManager.ResolveUserAsync(user))!;
}
private static async Task DoChecked(Func<Task<IdentityResult>> action)
if (claimsToRemove.Count > 0)
{
var result = await action();
var result = await userManager.RemoveClaimsAsync(user, claimsToRemove);
if (!result.Succeeded)
{
throw new ValidationException(result.Errors.Select(x => new ValidationError(x.Localize())).ToList());
}
return result;
}
public static Task<IdentityResult> SyncClaims(this UserManager<IdentityUser> userManager, IdentityUser user, UserValues values)
{
return values.SyncClaims(userManager, user);
}
public static string Localize(this IdentityResult result)
if (claimsToAdd.Count > 0)
{
return string.Join(". ", result.Errors.Select(x => x.Localize()));
return await userManager.AddClaimsAsync(user, claimsToAdd);
}
public static string Localize(this IdentityError error)
{
if (!string.IsNullOrWhiteSpace(error.Code))
{
return T.Get($"dotnet_identity_{error.Code}", error.Description);
}
else
{
return error.Description;
}
return IdentityResult.Success;
}
}
}

109
backend/src/Squidex.Domain.Users/UserValues.cs

@ -5,14 +5,9 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Identity;
using Squidex.Infrastructure.Security;
using Squidex.Shared.Identity;
namespace Squidex.Domain.Users
{
@ -41,109 +36,5 @@ namespace Squidex.Domain.Users
public List<Claim>? CustomClaims { get; set; }
public List<(string Name, string Value)>? Properties { get; set; }
internal async Task<IdentityResult> SyncClaims(UserManager<IdentityUser> userManager, IdentityUser user)
{
var current = await userManager.GetClaimsAsync(user);
var claimsToRemove = new List<Claim>();
var claimsToAdd = new List<Claim>();
void RemoveClaims(Func<Claim, bool> predicate)
{
claimsToAdd.RemoveAll(x => predicate(x));
claimsToRemove.AddRange(current.Where(predicate));
}
void AddClaim(string type, string value)
{
claimsToAdd.Add(new Claim(type, value));
}
void SyncString(string type, string? value)
{
if (value != null)
{
RemoveClaims(x => x.Type == type);
if (!string.IsNullOrWhiteSpace(value))
{
AddClaim(type, value);
}
}
}
void SyncBoolean(string type, bool? value)
{
if (value != null)
{
RemoveClaims(x => x.Type == type);
if (value == true)
{
AddClaim(type, value.ToString()!);
}
}
}
SyncString(SquidexClaimTypes.ClientSecret, ClientSecret);
SyncString(SquidexClaimTypes.DisplayName, DisplayName);
SyncString(SquidexClaimTypes.PictureUrl, PictureUrl);
SyncBoolean(SquidexClaimTypes.Hidden, Hidden);
SyncBoolean(SquidexClaimTypes.Invited, Invited);
SyncBoolean(SquidexClaimTypes.Consent, Consent);
SyncBoolean(SquidexClaimTypes.ConsentForEmails, ConsentForEmails);
if (Permissions != null)
{
RemoveClaims(x => x.Type == SquidexClaimTypes.Permissions);
foreach (var permission in Permissions)
{
AddClaim(SquidexClaimTypes.Permissions, permission.Id);
}
}
if (Properties != null)
{
RemoveClaims(x => x.Type.StartsWith(SquidexClaimTypes.CustomPrefix, StringComparison.OrdinalIgnoreCase));
foreach (var (name, value) in Properties)
{
AddClaim($"{SquidexClaimTypes.CustomPrefix}:{name}", value);
}
}
if (CustomClaims != null)
{
foreach (var group in CustomClaims.GroupBy(x => x.Type))
{
RemoveClaims(x => x.Type == group.Key);
foreach (var claim in group)
{
AddClaim(claim.Type, claim.Value);
}
}
}
if (claimsToRemove.Count > 0)
{
var result = await userManager.RemoveClaimsAsync(user, claimsToRemove);
if (!result.Succeeded)
{
return result;
}
}
if (claimsToAdd.Count > 0)
{
return await userManager.AddClaimsAsync(user, claimsToAdd);
}
return IdentityResult.Success;
}
}
}

20
backend/src/Squidex.Domain.Users/UserWithClaims.cs

@ -7,20 +7,16 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using Microsoft.AspNetCore.Identity;
using Squidex.Infrastructure;
using Squidex.Shared.Users;
namespace Squidex.Domain.Users
{
public sealed class UserWithClaims : IUser
internal sealed class UserWithClaims : IUser
{
public IdentityUser Identity { get; }
public List<Claim> Claims { get; }
public string Id
{
get { return Identity.Id; }
@ -36,19 +32,15 @@ namespace Squidex.Domain.Users
get { return Identity.LockoutEnd > DateTime.UtcNow; }
}
IReadOnlyList<Claim> IUser.Claims
{
get { return Claims; }
}
public IReadOnlyList<Claim> Claims { get; }
public UserWithClaims(IdentityUser user, IEnumerable<Claim> claims)
{
Guard.NotNull(user, nameof(user));
Guard.NotNull(claims, nameof(claims));
object IUser.Identity => Identity;
public UserWithClaims(IdentityUser user, IReadOnlyList<Claim> claims)
{
Identity = user;
Claims = claims.ToList();
Claims = claims;
}
}
}

2
backend/src/Squidex.Infrastructure/Validation/LocalizedCompareAttribute.cs

@ -24,7 +24,7 @@ namespace Squidex.Infrastructure.Validation
var other = T.Get($"common.{OtherProperty.ToCamelCase()}", OtherProperty);
return T.Get("annotations_Compare", base.FormatErrorMessage(name), new { property, other });
return T.Get("annotations_Compare", base.FormatErrorMessage(name), new { name = property, other });
}
}
}

2
backend/src/Squidex.Infrastructure/Validation/LocalizedEmailAddressAttribute.cs

@ -24,7 +24,7 @@ namespace Squidex.Infrastructure.Validation
{
var property = T.Get($"common.{name.ToCamelCase()}", name);
return T.Get("annotations_EmailAddress", base.FormatErrorMessage(name), new { property });
return T.Get("annotations_EmailAddress", base.FormatErrorMessage(name), new { name = property });
}
}
}

2
backend/src/Squidex.Infrastructure/Validation/LocalizedRangeAttribute.cs

@ -32,7 +32,7 @@ namespace Squidex.Infrastructure.Validation
var min = Minimum;
var max = Maximum;
return T.Get("annotations_Range", base.FormatErrorMessage(name), new { property, min, max });
return T.Get("annotations_Range", base.FormatErrorMessage(name), new { name = property, min, max });
}
}
}

2
backend/src/Squidex.Infrastructure/Validation/LocalizedRegularExpressionAttribute.cs

@ -24,7 +24,7 @@ namespace Squidex.Infrastructure.Validation
{
var property = T.Get($"common.{name.ToCamelCase()}", name);
return T.Get("annotations_RegularExpression", base.FormatErrorMessage(name), new { property });
return T.Get("annotations_RegularExpression", base.FormatErrorMessage(name), new { name = property });
}
}
}

2
backend/src/Squidex.Infrastructure/Validation/LocalizedRequiredAttribute.cs

@ -19,7 +19,7 @@ namespace Squidex.Infrastructure.Validation
{
var property = T.Get($"common.{name.ToCamelCase()}", name);
return T.Get("annotations_Required", base.FormatErrorMessage(name), new { property });
return T.Get("annotations_Required", base.FormatErrorMessage(name), new { name = property });
}
}
}

2
backend/src/Squidex.Infrastructure/Validation/LocalizedStringLengthAttribute.cs

@ -25,7 +25,7 @@ namespace Squidex.Infrastructure.Validation
var min = MinimumLength;
var max = MaximumLength;
var args = new { property, min, max };
var args = new { name = property, min, max };
if (min > 0)
{

47
backend/src/Squidex.Shared/Identity/ClaimsPrincipalExtensions.cs

@ -1,47 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using Squidex.Infrastructure.Security;
namespace Squidex.Shared.Identity
{
public static class ClaimsPrincipalExtensions
{
public static void SetDisplayName(this ClaimsIdentity identity, string displayName)
{
identity.AddClaim(new Claim(SquidexClaimTypes.DisplayName, displayName));
}
public static void SetPictureUrl(this ClaimsIdentity identity, string pictureUrl)
{
identity.AddClaim(new Claim(SquidexClaimTypes.PictureUrl, pictureUrl));
}
public static PermissionSet Permissions(this ClaimsPrincipal principal)
{
return new PermissionSet(principal.Claims
.Where(x =>
(x.Type == SquidexClaimTypes.Permissions ||
x.Type == SquidexClaimTypes.PermissionsClient) &&
!string.IsNullOrWhiteSpace(x.Value))
.Select(x => new Permission(x.Value)));
}
public static IEnumerable<Claim> GetSquidexClaims(this ClaimsPrincipal principal)
{
return principal.Claims
.Where(x =>
(x.Type.StartsWith(SquidexClaimTypes.Prefix, StringComparison.Ordinal) ||
x.Type.StartsWith(SquidexClaimTypes.PrefixClient, StringComparison.Ordinal)) &&
!string.IsNullOrWhiteSpace(x.Value));
}
}
}

20
backend/src/Squidex.Shared/Identity/SquidexClaimTypes.cs

@ -9,31 +9,25 @@ namespace Squidex.Shared.Identity
{
public static class SquidexClaimTypes
{
public static readonly string DisplayName = "urn:squidex:name";
public static readonly string PictureUrl = "urn:squidex:picture";
public static readonly string NotifoKey = "urn:squidex:notifo";
public static readonly string ClientSecret = "urn:squidex:clientSecret";
public static readonly string Consent = "urn:squidex:consent";
public static readonly string ConsentForEmails = "urn:squidex:consent:emails";
public static readonly string CustomPrefix = "urn:squidex:custom";
public static readonly string DisplayName = "urn:squidex:name";
public static readonly string Hidden = "urn:squidex:hidden";
public static readonly string Invited = "urn:squidex:invited";
public static readonly string CustomPrefix = "urn:squidex:custom";
public static readonly string NotifoKey = "urn:squidex:notifo";
public static readonly string Permissions = "urn:squidex:permissions";
public static readonly string PermissionsClient = "client_urn:squidex:permissions";
public static readonly string ClientSecret = "urn:squidex:clientSecret";
public static readonly string Prefix = "urn:squidex:";
public static readonly string PrefixClient = "client_urn:squidex:";
public static readonly string PictureUrl = "urn:squidex:picture";
public static readonly string PictureUrlStore = "store";
}

159
backend/src/Squidex.Shared/Identity/SquidexClaimsExtensions.cs

@ -0,0 +1,159 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using Squidex.Infrastructure.Security;
namespace Squidex.Shared.Identity
{
public static class SquidexClaimsExtensions
{
private const string ClientPrefix = "client_";
public static PermissionSet Permissions(this IEnumerable<Claim> user)
{
return new PermissionSet(user.GetClaims(SquidexClaimTypes.Permissions).Select(x => new Permission(x.Value)));
}
public static bool IsHidden(this IEnumerable<Claim> user)
{
return user.HasClaimValue(SquidexClaimTypes.Hidden, "true");
}
public static bool HasConsent(this IEnumerable<Claim> user)
{
return user.HasClaimValue(SquidexClaimTypes.Consent, "true");
}
public static bool HasConsentForEmails(this IEnumerable<Claim> user)
{
return user.HasClaimValue(SquidexClaimTypes.ConsentForEmails, "true");
}
public static bool HasDisplayName(this IEnumerable<Claim> user)
{
return user.HasClaim(SquidexClaimTypes.DisplayName);
}
public static bool HasPictureUrl(this IEnumerable<Claim> user)
{
return user.HasClaim(SquidexClaimTypes.PictureUrl);
}
public static bool IsPictureUrlStored(this IEnumerable<Claim> user)
{
return user.HasClaimValue(SquidexClaimTypes.PictureUrl, SquidexClaimTypes.PictureUrlStore);
}
public static string? ClientSecret(this IEnumerable<Claim> user)
{
return user.GetClaimValue(SquidexClaimTypes.ClientSecret);
}
public static string? PictureUrl(this IEnumerable<Claim> user)
{
return user.GetClaimValue(SquidexClaimTypes.PictureUrl);
}
public static string? DisplayName(this IEnumerable<Claim> user)
{
return user.GetClaimValue(SquidexClaimTypes.DisplayName);
}
public static bool HasClaim(this IEnumerable<Claim> user, string type)
{
return user.GetClaims(type).Any();
}
public static bool HasClaimValue(this IEnumerable<Claim> user, string type, string value)
{
return user.GetClaims(type).Any(x => string.Equals(x.Value, value, StringComparison.OrdinalIgnoreCase));
}
public static IEnumerable<Claim> GetSquidexClaims(this IEnumerable<Claim> user)
{
const string prefix = "urn:squidex:";
foreach (var claim in user)
{
var type = GetType(claim);
if (type.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
{
yield return claim;
}
}
}
public static IEnumerable<(string Name, string Value)> GetCustomProperties(this IEnumerable<Claim> user)
{
foreach (var claim in user)
{
var type = GetType(claim);
if (type.StartsWith(SquidexClaimTypes.CustomPrefix, StringComparison.OrdinalIgnoreCase))
{
var name = type[(SquidexClaimTypes.CustomPrefix.Length + 1)..].ToString();
yield return (name, claim.Value);
}
}
}
public static string? PictureNormalizedUrl(this IEnumerable<Claim> user)
{
var url = user.FirstOrDefault(x => x.Type == SquidexClaimTypes.PictureUrl)?.Value;
if (!string.IsNullOrWhiteSpace(url) && Uri.IsWellFormedUriString(url, UriKind.Absolute) && url.Contains("gravatar"))
{
if (url.Contains("?"))
{
url += "&d=404";
}
else
{
url += "?d=404";
}
}
return url;
}
private static string? GetClaimValue(this IEnumerable<Claim> user, string type)
{
return user.GetClaims(type).FirstOrDefault()?.Value;
}
private static IEnumerable<Claim> GetClaims(this IEnumerable<Claim> user, string request)
{
foreach (var claim in user)
{
var type = GetType(claim);
if (type.Equals(request, StringComparison.OrdinalIgnoreCase))
{
yield return claim;
}
}
}
private static ReadOnlySpan<char> GetType(Claim claim)
{
var type = claim.Type.AsSpan();
if (type.StartsWith(ClientPrefix, StringComparison.OrdinalIgnoreCase))
{
type = type[ClientPrefix.Length..];
}
return type;
}
}
}

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

@ -463,9 +463,6 @@
<data name="contents.invalidGeolocationLongitude" xml:space="preserve">
<value>La longitude deve essere tra -180 and 180.</value>
</data>
<data name="contents.invalidGeolocationMoreProperties" xml:space="preserve">
<value>E' possibile impostare la geolocalizzazione solo impostando latitudine e longitudine.</value>
</data>
<data name="contents.invalidNumber" xml:space="preserve">
<value>Errore nel json,, atteso un number.</value>
</data>
@ -931,6 +928,9 @@
<data name="users.consent.title" xml:space="preserve">
<value>Acconsento</value>
</data>
<data name="users.deleteYourselfError" xml:space="preserve">
<value>You cannot delete yourself.</value>
</data>
<data name="users.error.headline" xml:space="preserve">
<value>Operazione non riuscita</value>
</data>

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

@ -463,9 +463,6 @@
<data name="contents.invalidGeolocationLongitude" xml:space="preserve">
<value>Lengtegraad moet tussen -180 en 180 liggen.</value>
</data>
<data name="contents.invalidGeolocationMoreProperties" xml:space="preserve">
<value>Geolocatie kan alleen de eigenschap lengte- en breedtegraad hebben.</value>
</data>
<data name="contents.invalidNumber" xml:space="preserve">
<value>Ongeldig json-type, verwacht aantal.</value>
</data>
@ -931,6 +928,9 @@
<data name="users.consent.title" xml:space="preserve">
<value>Toestemming</value>
</data>
<data name="users.deleteYourselfError" xml:space="preserve">
<value>You cannot delete yourself.</value>
</data>
<data name="users.error.headline" xml:space="preserve">
<value>Bewerking mislukt</value>
</data>

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

@ -463,9 +463,6 @@
<data name="contents.invalidGeolocationLongitude" xml:space="preserve">
<value>Longitude must be between -180 and 180.</value>
</data>
<data name="contents.invalidGeolocationMoreProperties" xml:space="preserve">
<value>Geolocation can only have latitude and longitude property.</value>
</data>
<data name="contents.invalidNumber" xml:space="preserve">
<value>Invalid json type, expected number.</value>
</data>
@ -931,6 +928,9 @@
<data name="users.consent.title" xml:space="preserve">
<value>Consent</value>
</data>
<data name="users.deleteYourselfError" xml:space="preserve">
<value>You cannot delete yourself.</value>
</data>
<data name="users.error.headline" xml:space="preserve">
<value>Operation failed</value>
</data>

28
backend/src/Squidex.Shared/Users/ClientUser.cs

@ -18,19 +18,6 @@ namespace Squidex.Shared.Users
private readonly RefToken token;
private readonly List<Claim> claims;
public ClientUser(RefToken token)
{
Guard.NotNull(token, nameof(token));
this.token = token;
claims = new List<Claim>
{
new Claim(OpenIdClaims.ClientId, token.Identifier),
new Claim(SquidexClaimTypes.DisplayName, token.ToString())
};
}
public string Id
{
get { return token.Identifier; }
@ -50,5 +37,20 @@ namespace Squidex.Shared.Users
{
get { return claims; }
}
public object Identity => throw new System.NotImplementedException();
public ClientUser(RefToken token)
{
Guard.NotNull(token, nameof(token));
this.token = token;
claims = new List<Claim>
{
new Claim(OpenIdClaims.ClientId, token.Identifier),
new Claim(SquidexClaimTypes.DisplayName, token.ToString())
};
}
}
}

4
backend/src/Squidex.Shared/Users/IUser.cs

@ -12,11 +12,13 @@ namespace Squidex.Shared.Users
{
public interface IUser
{
bool IsLocked { get; }
string Id { get; }
string Email { get; }
bool IsLocked { get; }
object Identity { get; }
IReadOnlyList<Claim> Claims { get; }
}

121
backend/src/Squidex.Shared/Users/UserExtensions.cs

@ -1,121 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Linq;
using Squidex.Infrastructure.Security;
using Squidex.Shared.Identity;
namespace Squidex.Shared.Users
{
public static class UserExtensions
{
public static PermissionSet Permissions(this IUser user)
{
return new PermissionSet(user.GetClaimValues(SquidexClaimTypes.Permissions).Select(x => new Permission(x)));
}
public static bool IsInvited(this IUser user)
{
return user.HasClaimValue(SquidexClaimTypes.Invited, "true");
}
public static bool IsHidden(this IUser user)
{
return user.HasClaimValue(SquidexClaimTypes.Hidden, "true");
}
public static bool HasConsent(this IUser user)
{
return user.HasClaimValue(SquidexClaimTypes.Consent, "true");
}
public static bool HasConsentForEmails(this IUser user)
{
return user.HasClaimValue(SquidexClaimTypes.ConsentForEmails, "true");
}
public static bool HasDisplayName(this IUser user)
{
return user.HasClaim(SquidexClaimTypes.DisplayName);
}
public static bool HasPictureUrl(this IUser user)
{
return user.HasClaim(SquidexClaimTypes.PictureUrl);
}
public static bool IsPictureUrlStored(this IUser user)
{
return user.HasClaimValue(SquidexClaimTypes.PictureUrl, SquidexClaimTypes.PictureUrlStore);
}
public static string? ClientSecret(this IUser user)
{
return user.GetClaimValue(SquidexClaimTypes.ClientSecret);
}
public static string? PictureUrl(this IUser user)
{
return user.GetClaimValue(SquidexClaimTypes.PictureUrl);
}
public static string? DisplayName(this IUser user)
{
return user.GetClaimValue(SquidexClaimTypes.DisplayName);
}
public static string? GetClaimValue(this IUser user, string type)
{
return user.Claims.FirstOrDefault(x => string.Equals(x.Type, type, StringComparison.OrdinalIgnoreCase))?.Value;
}
public static string[] GetClaimValues(this IUser user, string type)
{
return user.Claims.Where(x => string.Equals(x.Type, type, StringComparison.OrdinalIgnoreCase))
.Select(x => x.Value).ToArray();
}
public static List<(string Name, string Value)> GetCustomProperties(this IUser user)
{
return user.Claims.Where(x => x.Type.StartsWith(SquidexClaimTypes.CustomPrefix, StringComparison.OrdinalIgnoreCase))
.Select(x => (x.Type[(SquidexClaimTypes.CustomPrefix.Length + 1)..], x.Value)).ToList();
}
public static bool HasClaim(this IUser user, string type)
{
return user.Claims.Any(x => string.Equals(x.Type, type, StringComparison.OrdinalIgnoreCase));
}
public static bool HasClaimValue(this IUser user, string type, string value)
{
return user.Claims.Any(x =>
string.Equals(x.Type, type, StringComparison.OrdinalIgnoreCase) &&
string.Equals(x.Value, value, StringComparison.OrdinalIgnoreCase));
}
public static string? PictureNormalizedUrl(this IUser user)
{
var url = user.Claims.FirstOrDefault(x => x.Type == SquidexClaimTypes.PictureUrl)?.Value;
if (!string.IsNullOrWhiteSpace(url) && Uri.IsWellFormedUriString(url, UriKind.Absolute) && url.Contains("gravatar"))
{
if (url.Contains("?"))
{
url += "&d=404";
}
else
{
url += "?d=404";
}
}
return url;
}
}
}

7
backend/src/Squidex/Areas/Api/Controllers/Apps/AppClientsController.cs

@ -6,6 +6,7 @@
// ==========================================================================
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Net.Http.Headers;
using Squidex.Areas.Api.Controllers.Apps.Models;
@ -41,7 +42,7 @@ namespace Squidex.Areas.Api.Controllers.Apps
/// </remarks>
[HttpGet]
[Route("apps/{app}/clients/")]
[ProducesResponseType(typeof(ClientsDto), 200)]
[ProducesResponseType(typeof(ClientsDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppClientsRead)]
[ApiCosts(0)]
public IActionResult GetClients(string app)
@ -100,7 +101,7 @@ namespace Squidex.Areas.Api.Controllers.Apps
/// </remarks>
[HttpPut]
[Route("apps/{app}/clients/{id}/")]
[ProducesResponseType(typeof(ClientsDto), 200)]
[ProducesResponseType(typeof(ClientsDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppClientsUpdate)]
[ApiCosts(1)]
public async Task<IActionResult> PutClient(string app, string id, [FromBody] UpdateClientDto request)
@ -126,7 +127,7 @@ namespace Squidex.Areas.Api.Controllers.Apps
/// </remarks>
[HttpDelete]
[Route("apps/{app}/clients/{id}/")]
[ProducesResponseType(typeof(ClientsDto), 200)]
[ProducesResponseType(typeof(ClientsDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppClientsDelete)]
[ApiCosts(1)]
public async Task<IActionResult> DeleteClient(string app, string id)

7
backend/src/Squidex/Areas/Api/Controllers/Apps/AppContributorsController.cs

@ -6,6 +6,7 @@
// ==========================================================================
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Net.Http.Headers;
using Squidex.Areas.Api.Controllers.Apps.Models;
@ -50,7 +51,7 @@ namespace Squidex.Areas.Api.Controllers.Apps
/// </returns>
[HttpGet]
[Route("apps/{app}/contributors/")]
[ProducesResponseType(typeof(ContributorsDto), 200)]
[ProducesResponseType(typeof(ContributorsDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppContributorsRead)]
[ApiCosts(0)]
public IActionResult GetContributors(string app)
@ -99,7 +100,7 @@ namespace Squidex.Areas.Api.Controllers.Apps
/// </returns>
[HttpDelete]
[Route("apps/{app}/contributors/me/")]
[ProducesResponseType(typeof(ContributorsDto), 200)]
[ProducesResponseType(typeof(ContributorsDto), StatusCodes.Status200OK)]
[ApiPermission]
[ApiCosts(1)]
public async Task<IActionResult> DeleteMyself(string app)
@ -122,7 +123,7 @@ namespace Squidex.Areas.Api.Controllers.Apps
/// </returns>
[HttpDelete]
[Route("apps/{app}/contributors/{id}/")]
[ProducesResponseType(typeof(ContributorsDto), 200)]
[ProducesResponseType(typeof(ContributorsDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppContributorsRevoke)]
[ApiCosts(1)]
public async Task<IActionResult> DeleteContributor(string app, string id)

7
backend/src/Squidex/Areas/Api/Controllers/Apps/AppLanguagesController.cs

@ -7,6 +7,7 @@
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Net.Http.Headers;
using Squidex.Areas.Api.Controllers.Apps.Models;
@ -40,7 +41,7 @@ namespace Squidex.Areas.Api.Controllers.Apps
/// </returns>
[HttpGet]
[Route("apps/{app}/languages/")]
[ProducesResponseType(typeof(AppLanguagesDto), 200)]
[ProducesResponseType(typeof(AppLanguagesDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppLanguagesRead)]
[ApiCosts(0)]
public IActionResult GetLanguages(string app)
@ -92,7 +93,7 @@ namespace Squidex.Areas.Api.Controllers.Apps
/// </returns>
[HttpPut]
[Route("apps/{app}/languages/{language}/")]
[ProducesResponseType(typeof(AppLanguagesDto), 200)]
[ProducesResponseType(typeof(AppLanguagesDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppLanguagesUpdate)]
[ApiCosts(1)]
public async Task<IActionResult> PutLanguage(string app, string language, [FromBody] UpdateLanguageDto request)
@ -116,7 +117,7 @@ namespace Squidex.Areas.Api.Controllers.Apps
/// </returns>
[HttpDelete]
[Route("apps/{app}/languages/{language}/")]
[ProducesResponseType(typeof(AppLanguagesDto), 200)]
[ProducesResponseType(typeof(AppLanguagesDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppLanguagesDelete)]
[ApiCosts(1)]
public async Task<IActionResult> DeleteLanguage(string app, string language)

7
backend/src/Squidex/Areas/Api/Controllers/Apps/AppPatternsController.cs

@ -6,6 +6,7 @@
// ==========================================================================
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Net.Http.Headers;
using Squidex.Areas.Api.Controllers.Apps.Models;
@ -42,7 +43,7 @@ namespace Squidex.Areas.Api.Controllers.Apps
/// </remarks>
[HttpGet]
[Route("apps/{app}/patterns/")]
[ProducesResponseType(typeof(PatternsDto), 200)]
[ProducesResponseType(typeof(PatternsDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppPatternsRead)]
[ApiCosts(0)]
public IActionResult GetPatterns(string app)
@ -94,7 +95,7 @@ namespace Squidex.Areas.Api.Controllers.Apps
/// </returns>
[HttpPut]
[Route("apps/{app}/patterns/{id}/")]
[ProducesResponseType(typeof(PatternsDto), 200)]
[ProducesResponseType(typeof(PatternsDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppPatternsUpdate)]
[ApiCosts(1)]
public async Task<IActionResult> PutPattern(string app, DomainId id, [FromBody] UpdatePatternDto request)
@ -120,7 +121,7 @@ namespace Squidex.Areas.Api.Controllers.Apps
/// </remarks>
[HttpDelete]
[Route("apps/{app}/patterns/{id}/")]
[ProducesResponseType(typeof(PatternsDto), 200)]
[ProducesResponseType(typeof(PatternsDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppPatternsDelete)]
[ApiCosts(1)]
public async Task<IActionResult> DeletePattern(string app, DomainId id)

9
backend/src/Squidex/Areas/Api/Controllers/Apps/AppRolesController.cs

@ -6,6 +6,7 @@
// ==========================================================================
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Net.Http.Headers;
using Squidex.Areas.Api.Controllers.Apps.Models;
@ -42,7 +43,7 @@ namespace Squidex.Areas.Api.Controllers.Apps
/// </returns>
[HttpGet]
[Route("apps/{app}/roles/")]
[ProducesResponseType(typeof(RolesDto), 200)]
[ProducesResponseType(typeof(RolesDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppRolesRead)]
[ApiCosts(0)]
public IActionResult GetRoles(string app)
@ -67,7 +68,7 @@ namespace Squidex.Areas.Api.Controllers.Apps
/// </returns>
[HttpGet]
[Route("apps/{app}/roles/permissions")]
[ProducesResponseType(typeof(string[]), 200)]
[ProducesResponseType(typeof(string[]), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppRolesRead)]
[ApiCosts(0)]
public IActionResult GetPermissions(string app)
@ -119,7 +120,7 @@ namespace Squidex.Areas.Api.Controllers.Apps
/// </returns>
[HttpPut]
[Route("apps/{app}/roles/{roleName}/")]
[ProducesResponseType(typeof(RolesDto), 200)]
[ProducesResponseType(typeof(RolesDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppRolesUpdate)]
[ApiCosts(1)]
public async Task<IActionResult> PutRole(string app, string roleName, [FromBody] UpdateRoleDto request)
@ -143,7 +144,7 @@ namespace Squidex.Areas.Api.Controllers.Apps
/// </returns>
[HttpDelete]
[Route("apps/{app}/roles/{roleName}/")]
[ProducesResponseType(typeof(RolesDto), 200)]
[ProducesResponseType(typeof(RolesDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppRolesDelete)]
[ApiCosts(1)]
public async Task<IActionResult> DeleteRole(string app, string roleName)

9
backend/src/Squidex/Areas/Api/Controllers/Apps/AppWorkflowsController.cs

@ -6,6 +6,7 @@
// ==========================================================================
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Net.Http.Headers;
using Squidex.Areas.Api.Controllers.Apps.Models;
@ -43,7 +44,7 @@ namespace Squidex.Areas.Api.Controllers.Apps
/// </returns>
[HttpGet]
[Route("apps/{app}/workflows/")]
[ProducesResponseType(typeof(WorkflowsDto), 200)]
[ProducesResponseType(typeof(WorkflowsDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppWorkflowsRead)]
[ApiCosts(0)]
public IActionResult GetWorkflows(string app)
@ -70,7 +71,7 @@ namespace Squidex.Areas.Api.Controllers.Apps
/// </returns>
[HttpPost]
[Route("apps/{app}/workflows/")]
[ProducesResponseType(typeof(WorkflowsDto), 200)]
[ProducesResponseType(typeof(WorkflowsDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppWorkflowsUpdate)]
[ApiCosts(1)]
public async Task<IActionResult> PostWorkflow(string app, [FromBody] AddWorkflowDto request)
@ -95,7 +96,7 @@ namespace Squidex.Areas.Api.Controllers.Apps
/// </returns>
[HttpPut]
[Route("apps/{app}/workflows/{id}")]
[ProducesResponseType(typeof(WorkflowsDto), 200)]
[ProducesResponseType(typeof(WorkflowsDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppWorkflowsUpdate)]
[ApiCosts(1)]
public async Task<IActionResult> PutWorkflow(string app, DomainId id, [FromBody] UpdateWorkflowDto request)
@ -118,7 +119,7 @@ namespace Squidex.Areas.Api.Controllers.Apps
/// </returns>
[HttpDelete]
[Route("apps/{app}/workflows/{id}")]
[ProducesResponseType(typeof(WorkflowsDto), 200)]
[ProducesResponseType(typeof(WorkflowsDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppWorkflowsUpdate)]
[ApiCosts(1)]
public async Task<IActionResult> DeleteWorkflow(string app, DomainId id)

12
backend/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs

@ -70,7 +70,7 @@ namespace Squidex.Areas.Api.Controllers.Apps
/// </remarks>
[HttpGet]
[Route("apps/")]
[ProducesResponseType(typeof(AppDto[]), 200)]
[ProducesResponseType(typeof(AppDto[]), StatusCodes.Status200OK)]
[ApiPermission]
[ApiCosts(0)]
public async Task<IActionResult> GetApps()
@ -102,7 +102,7 @@ namespace Squidex.Areas.Api.Controllers.Apps
/// </returns>
[HttpGet]
[Route("apps/{app}")]
[ProducesResponseType(typeof(AppDto), 200)]
[ProducesResponseType(typeof(AppDto), StatusCodes.Status200OK)]
[ApiPermission]
[ApiCosts(0)]
public IActionResult GetApp(string app)
@ -158,7 +158,7 @@ namespace Squidex.Areas.Api.Controllers.Apps
/// </returns>
[HttpPut]
[Route("apps/{app}/")]
[ProducesResponseType(typeof(AppDto), 200)]
[ProducesResponseType(typeof(AppDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppUpdate)]
[ApiCosts(0)]
public async Task<IActionResult> UpdateApp(string app, [FromBody] UpdateAppDto request)
@ -180,7 +180,7 @@ namespace Squidex.Areas.Api.Controllers.Apps
/// </returns>
[HttpPost]
[Route("apps/{app}/image")]
[ProducesResponseType(typeof(AppDto), 200)]
[ProducesResponseType(typeof(AppDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppUpdateImage)]
[ApiCosts(0)]
public async Task<IActionResult> UploadImage(string app, IFormFile file)
@ -200,7 +200,7 @@ namespace Squidex.Areas.Api.Controllers.Apps
/// </returns>
[HttpGet]
[Route("apps/{app}/image")]
[ProducesResponseType(typeof(FileResult), 200)]
[ProducesResponseType(typeof(FileResult), StatusCodes.Status200OK)]
[AllowAnonymous]
[ApiCosts(0)]
public IActionResult GetImage(string app)
@ -271,7 +271,7 @@ namespace Squidex.Areas.Api.Controllers.Apps
/// </returns>
[HttpDelete]
[Route("apps/{app}/image")]
[ProducesResponseType(typeof(AppDto), 200)]
[ProducesResponseType(typeof(AppDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppUpdateImage)]
[ApiCosts(0)]
public async Task<IActionResult> DeleteImage(string app)

3
backend/src/Squidex/Areas/Api/Controllers/Apps/Models/ContributorDto.cs

@ -8,6 +8,7 @@
using System.Collections.Generic;
using Squidex.Infrastructure.Translations;
using Squidex.Infrastructure.Validation;
using Squidex.Shared.Identity;
using Squidex.Shared.Users;
using Squidex.Web;
@ -49,7 +50,7 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
{
if (users.TryGetValue(ContributorId, out var user))
{
ContributorName = user.DisplayName()!;
ContributorName = user.Claims.DisplayName()!;
ContributorEmail = user.Email;
}
else

5
backend/src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs

@ -10,6 +10,7 @@ using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Net.Http.Headers;
using Squidex.Areas.Api.Controllers.Assets.Models;
@ -67,7 +68,7 @@ namespace Squidex.Areas.Api.Controllers.Assets
/// </returns>
[HttpGet]
[Route("assets/{app}/{idOrSlug}/{*more}")]
[ProducesResponseType(typeof(FileResult), 200)]
[ProducesResponseType(typeof(FileResult), StatusCodes.Status200OK)]
[ApiPermission]
[ApiCosts(0.5)]
[AllowAnonymous]
@ -94,7 +95,7 @@ namespace Squidex.Areas.Api.Controllers.Assets
/// </returns>
[HttpGet]
[Route("assets/{id}/")]
[ProducesResponseType(typeof(FileResult), 200)]
[ProducesResponseType(typeof(FileResult), StatusCodes.Status200OK)]
[ApiPermission]
[ApiCosts(0.5)]
[AllowAnonymous]

7
backend/src/Squidex/Areas/Api/Controllers/Assets/AssetFoldersController.cs

@ -6,6 +6,7 @@
// ==========================================================================
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Net.Http.Headers;
using Squidex.Areas.Api.Controllers.Assets.Models;
@ -47,7 +48,7 @@ namespace Squidex.Areas.Api.Controllers.Assets
/// </remarks>
[HttpGet]
[Route("apps/{app}/assets/folders", Order = -1)]
[ProducesResponseType(typeof(AssetsDto), 200)]
[ProducesResponseType(typeof(AssetsDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppAssetsRead)]
[ApiCosts(1)]
public async Task<IActionResult> GetAssetFolders(string app, [FromQuery] DomainId parentId)
@ -106,7 +107,7 @@ namespace Squidex.Areas.Api.Controllers.Assets
/// </returns>
[HttpPut]
[Route("apps/{app}/assets/folders/{id}/", Order = -1)]
[ProducesResponseType(typeof(AssetDto), 200)]
[ProducesResponseType(typeof(AssetDto), StatusCodes.Status200OK)]
[AssetRequestSizeLimit]
[ApiPermissionOrAnonymous(Permissions.AppAssetsUpdate)]
[ApiCosts(1)]
@ -132,7 +133,7 @@ namespace Squidex.Areas.Api.Controllers.Assets
/// </returns>
[HttpPut]
[Route("apps/{app}/assets/folders/{id}/parent", Order = -1)]
[ProducesResponseType(typeof(AssetDto), 200)]
[ProducesResponseType(typeof(AssetDto), StatusCodes.Status200OK)]
[AssetRequestSizeLimit]
[ApiPermissionOrAnonymous(Permissions.AppAssetsUpdate)]
[ApiCosts(1)]

14
backend/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs

@ -64,7 +64,7 @@ namespace Squidex.Areas.Api.Controllers.Assets
/// </remarks>
[HttpGet]
[Route("apps/{app}/assets/tags")]
[ProducesResponseType(typeof(Dictionary<string, int>), 200)]
[ProducesResponseType(typeof(Dictionary<string, int>), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppAssetsRead)]
[ApiCosts(1)]
public async Task<IActionResult> GetTags(string app)
@ -92,7 +92,7 @@ namespace Squidex.Areas.Api.Controllers.Assets
/// </remarks>
[HttpGet]
[Route("apps/{app}/assets/")]
[ProducesResponseType(typeof(AssetsDto), 200)]
[ProducesResponseType(typeof(AssetsDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppAssetsRead)]
[ApiCosts(1)]
public async Task<IActionResult> GetAssets(string app, [FromQuery] DomainId? parentId, [FromQuery] string? ids = null, [FromQuery] string? q = null)
@ -121,7 +121,7 @@ namespace Squidex.Areas.Api.Controllers.Assets
/// </remarks>
[HttpPost]
[Route("apps/{app}/assets/query")]
[ProducesResponseType(typeof(AssetsDto), 200)]
[ProducesResponseType(typeof(AssetsDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppAssetsRead)]
[ApiCosts(1)]
public async Task<IActionResult> GetAssetsPost(string app, [FromBody] QueryDto query)
@ -147,7 +147,7 @@ namespace Squidex.Areas.Api.Controllers.Assets
/// </returns>
[HttpGet]
[Route("apps/{app}/assets/{id}/")]
[ProducesResponseType(typeof(AssetDto), 200)]
[ProducesResponseType(typeof(AssetDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppAssetsRead)]
[ApiCosts(1)]
public async Task<IActionResult> GetAsset(string app, DomainId id)
@ -223,7 +223,7 @@ namespace Squidex.Areas.Api.Controllers.Assets
/// </remarks>
[HttpPut]
[Route("apps/{app}/assets/{id}/content/")]
[ProducesResponseType(typeof(AssetDto), 200)]
[ProducesResponseType(typeof(AssetDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppAssetsUpload)]
[ApiCosts(1)]
public async Task<IActionResult> PutAssetContent(string app, DomainId id, IFormFile file)
@ -250,7 +250,7 @@ namespace Squidex.Areas.Api.Controllers.Assets
/// </returns>
[HttpPut]
[Route("apps/{app}/assets/{id}/")]
[ProducesResponseType(typeof(AssetDto), 200)]
[ProducesResponseType(typeof(AssetDto), StatusCodes.Status200OK)]
[AssetRequestSizeLimit]
[ApiPermissionOrAnonymous(Permissions.AppAssetsUpdate)]
[ApiCosts(1)]
@ -276,7 +276,7 @@ namespace Squidex.Areas.Api.Controllers.Assets
/// </returns>
[HttpPut]
[Route("apps/{app}/assets/{id}/parent")]
[ProducesResponseType(typeof(AssetDto), 200)]
[ProducesResponseType(typeof(AssetDto), StatusCodes.Status200OK)]
[AssetRequestSizeLimit]
[ApiPermissionOrAnonymous(Permissions.AppAssetsUpdate)]
[ApiCosts(1)]

3
backend/src/Squidex/Areas/Api/Controllers/Backups/BackupContentController.cs

@ -7,6 +7,7 @@
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Squidex.Domain.Apps.Entities.Backup;
using Squidex.Infrastructure;
@ -45,7 +46,7 @@ namespace Squidex.Areas.Api.Controllers.Backups
[HttpGet]
[Route("apps/{app}/backups/{id}")]
[ResponseCache(Duration = 3600 * 24 * 30)]
[ProducesResponseType(typeof(FileResult), 200)]
[ProducesResponseType(typeof(FileResult), StatusCodes.Status200OK)]
[ApiCosts(0)]
[AllowAnonymous]
public async Task<IActionResult> GetBackupContent(string app, DomainId id)

7
backend/src/Squidex/Areas/Api/Controllers/Backups/BackupsController.cs

@ -7,6 +7,7 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Squidex.Areas.Api.Controllers.Backups.Models;
using Squidex.Domain.Apps.Entities.Backup;
@ -43,7 +44,7 @@ namespace Squidex.Areas.Api.Controllers.Backups
/// </returns>
[HttpGet]
[Route("apps/{app}/backups/")]
[ProducesResponseType(typeof(BackupJobsDto), 200)]
[ProducesResponseType(typeof(BackupJobsDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppBackupsRead)]
[ApiCosts(0)]
public async Task<IActionResult> GetBackups(string app)
@ -66,7 +67,7 @@ namespace Squidex.Areas.Api.Controllers.Backups
/// </returns>
[HttpPost]
[Route("apps/{app}/backups/")]
[ProducesResponseType(typeof(List<BackupJobDto>), 200)]
[ProducesResponseType(typeof(List<BackupJobDto>), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppBackupsCreate)]
[ApiCosts(0)]
public IActionResult PostBackup(string app)
@ -87,7 +88,7 @@ namespace Squidex.Areas.Api.Controllers.Backups
/// </returns>
[HttpDelete]
[Route("apps/{app}/backups/{id}")]
[ProducesResponseType(typeof(List<BackupJobDto>), 200)]
[ProducesResponseType(typeof(List<BackupJobDto>), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppBackupsDelete)]
[ApiCosts(0)]
public async Task<IActionResult> DeleteBackup(string app, DomainId id)

3
backend/src/Squidex/Areas/Api/Controllers/Backups/RestoreController.cs

@ -6,6 +6,7 @@
// ==========================================================================
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Squidex.Areas.Api.Controllers.Backups.Models;
using Squidex.Domain.Apps.Entities.Backup;
@ -38,7 +39,7 @@ namespace Squidex.Areas.Api.Controllers.Backups
/// </returns>
[HttpGet]
[Route("apps/restore/")]
[ProducesResponseType(typeof(RestoreJobDto), 200)]
[ProducesResponseType(typeof(RestoreJobDto), StatusCodes.Status200OK)]
[ApiPermission(Permissions.AdminRestore)]
public async Task<IActionResult> GetRestoreJob()
{

3
backend/src/Squidex/Areas/Api/Controllers/Comments/CommentsController.cs

@ -6,6 +6,7 @@
// ==========================================================================
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Net.Http.Headers;
using Squidex.Areas.Api.Controllers.Comments.Models;
@ -47,7 +48,7 @@ namespace Squidex.Areas.Api.Controllers.Comments
/// </returns>
[HttpGet]
[Route("apps/{app}/comments/{commentsId}")]
[ProducesResponseType(typeof(CommentsDto), 200)]
[ProducesResponseType(typeof(CommentsDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppCommentsRead)]
[ApiCosts(0)]
public async Task<IActionResult> GetComments(string app, DomainId commentsId, [FromQuery] long version = EtagVersion.Any)

3
backend/src/Squidex/Areas/Api/Controllers/Comments/Notifications/UserNotificationsController.cs

@ -6,6 +6,7 @@
// ==========================================================================
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Net.Http.Headers;
using Squidex.Areas.Api.Controllers.Comments.Models;
@ -47,7 +48,7 @@ namespace Squidex.Areas.Api.Controllers.Comments.Notifications
/// </returns>
[HttpGet]
[Route("users/{userId}/notifications")]
[ProducesResponseType(typeof(CommentsDto), 200)]
[ProducesResponseType(typeof(CommentsDto), StatusCodes.Status200OK)]
[ApiPermission]
public async Task<IActionResult> GetNotifications(DomainId userId, [FromQuery] long version = EtagVersion.Any)
{

31
backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs

@ -7,6 +7,7 @@
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Squidex.Areas.Api.Controllers.Contents.Models;
using Squidex.Domain.Apps.Core.Contents;
@ -149,7 +150,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
/// </remarks>
[HttpGet]
[Route("content/{app}/")]
[ProducesResponseType(typeof(ContentsDto), 200)]
[ProducesResponseType(typeof(ContentsDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous]
[ApiCosts(1)]
public async Task<IActionResult> GetAllContents(string app, [FromQuery] string ids)
@ -178,7 +179,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
/// </remarks>
[HttpPost]
[Route("content/{app}/")]
[ProducesResponseType(typeof(ContentsDto), 200)]
[ProducesResponseType(typeof(ContentsDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous]
[ApiCosts(1)]
public async Task<IActionResult> GetAllContentsPost(string app, [FromBody] ContentsIdsQueryDto query)
@ -209,7 +210,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
/// </remarks>
[HttpGet]
[Route("content/{app}/{name}/")]
[ProducesResponseType(typeof(ContentsDto), 200)]
[ProducesResponseType(typeof(ContentsDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous]
[ApiCosts(1)]
public async Task<IActionResult> GetContents(string app, string name, [FromQuery] string? ids = null, [FromQuery] string? q = null)
@ -241,7 +242,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
/// </remarks>
[HttpPost]
[Route("content/{app}/{name}/query")]
[ProducesResponseType(typeof(ContentsDto), 200)]
[ProducesResponseType(typeof(ContentsDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous]
[ApiCosts(1)]
public async Task<IActionResult> GetContentsPost(string app, string name, [FromBody] QueryDto query)
@ -273,7 +274,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
/// </remarks>
[HttpGet]
[Route("content/{app}/{name}/{id}/")]
[ProducesResponseType(typeof(ContentDto), 200)]
[ProducesResponseType(typeof(ContentDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous]
[ApiCosts(1)]
public async Task<IActionResult> GetContent(string app, string name, DomainId id)
@ -333,7 +334,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
/// </remarks>
[HttpGet]
[Route("content/{app}/{name}/{id}/references")]
[ProducesResponseType(typeof(ContentsDto), 200)]
[ProducesResponseType(typeof(ContentsDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous]
[ApiCosts(1)]
public async Task<IActionResult> GetReferences(string app, string name, DomainId id, [FromQuery] string? q = null)
@ -364,7 +365,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
/// </remarks>
[HttpGet]
[Route("content/{app}/{name}/{id}/referencing")]
[ProducesResponseType(typeof(ContentsDto), 200)]
[ProducesResponseType(typeof(ContentsDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous]
[ApiCosts(1)]
public async Task<IActionResult> GetReferencing(string app, string name, DomainId id, [FromQuery] string? q = null)
@ -462,7 +463,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
/// </remarks>
[HttpPost]
[Route("content/{app}/{name}/import")]
[ProducesResponseType(typeof(BulkResultDto[]), 200)]
[ProducesResponseType(typeof(BulkResultDto[]), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppContentsCreate)]
[ApiCosts(5)]
public async Task<IActionResult> PostContents(string app, string name, [FromBody] ImportContentsDto request)
@ -493,7 +494,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
/// </remarks>
[HttpPost]
[Route("content/{app}/{name}/bulk")]
[ProducesResponseType(typeof(BulkResultDto[]), 200)]
[ProducesResponseType(typeof(BulkResultDto[]), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppContents)]
[ApiCosts(5)]
public async Task<IActionResult> BulkContents(string app, string name, [FromBody] BulkUpdateDto request)
@ -526,7 +527,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
/// </remarks>
[HttpPost]
[Route("content/{app}/{name}/{id}/")]
[ProducesResponseType(typeof(ContentsDto), 200)]
[ProducesResponseType(typeof(ContentsDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppContentsUpsert)]
[ApiCosts(1)]
public async Task<IActionResult> PostContent(string app, string name, DomainId id, [FromBody] NamedContentData request, [FromQuery] bool publish = false)
@ -555,7 +556,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
/// </remarks>
[HttpPut]
[Route("content/{app}/{name}/{id}/")]
[ProducesResponseType(typeof(ContentsDto), 200)]
[ProducesResponseType(typeof(ContentsDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppContentsUpdate)]
[ApiCosts(1)]
public async Task<IActionResult> PutContent(string app, string name, DomainId id, [FromBody] NamedContentData request)
@ -584,7 +585,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
/// </remarks>
[HttpPatch]
[Route("content/{app}/{name}/{id}/")]
[ProducesResponseType(typeof(ContentsDto), 200)]
[ProducesResponseType(typeof(ContentsDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppContentsUpdate)]
[ApiCosts(1)]
public async Task<IActionResult> PatchContent(string app, string name, DomainId id, [FromBody] NamedContentData request)
@ -613,7 +614,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
/// </remarks>
[HttpPut]
[Route("content/{app}/{name}/{id}/status/")]
[ProducesResponseType(typeof(ContentsDto), 200)]
[ProducesResponseType(typeof(ContentsDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppContentsUpdate)]
[ApiCosts(1)]
public async Task<IActionResult> PutContentStatus(string app, string name, DomainId id, ChangeStatusDto request)
@ -640,7 +641,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
/// </remarks>
[HttpPost]
[Route("content/{app}/{name}/{id}/draft/")]
[ProducesResponseType(typeof(ContentsDto), 200)]
[ProducesResponseType(typeof(ContentsDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppContentsVersionCreate)]
[ApiCosts(1)]
public async Task<IActionResult> CreateDraft(string app, string name, DomainId id)
@ -667,7 +668,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
/// </remarks>
[HttpDelete]
[Route("content/{app}/{name}/{id}/draft/")]
[ProducesResponseType(typeof(ContentsDto), 200)]
[ProducesResponseType(typeof(ContentsDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppContentsDelete)]
[ApiCosts(1)]
public async Task<IActionResult> DeleteVersion(string app, string name, DomainId id)

9
backend/src/Squidex/Areas/Api/Controllers/EventConsumers/EventConsumersController.cs

@ -6,6 +6,7 @@
// ==========================================================================
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Orleans;
using Squidex.Areas.Api.Controllers.EventConsumers.Models;
@ -29,7 +30,7 @@ namespace Squidex.Areas.Api.Controllers.EventConsumers
[HttpGet]
[Route("event-consumers/")]
[ProducesResponseType(typeof(EventConsumersDto), 200)]
[ProducesResponseType(typeof(EventConsumersDto), StatusCodes.Status200OK)]
[ApiPermission(Permissions.AdminEventsRead)]
public async Task<IActionResult> GetEventConsumers()
{
@ -42,7 +43,7 @@ namespace Squidex.Areas.Api.Controllers.EventConsumers
[HttpPut]
[Route("event-consumers/{consumerName}/start/")]
[ProducesResponseType(typeof(EventConsumerDto), 200)]
[ProducesResponseType(typeof(EventConsumerDto), StatusCodes.Status200OK)]
[ApiPermission(Permissions.AdminEventsManage)]
public async Task<IActionResult> StartEventConsumer(string consumerName)
{
@ -55,7 +56,7 @@ namespace Squidex.Areas.Api.Controllers.EventConsumers
[HttpPut]
[Route("event-consumers/{consumerName}/stop/")]
[ProducesResponseType(typeof(EventConsumerDto), 200)]
[ProducesResponseType(typeof(EventConsumerDto), StatusCodes.Status200OK)]
[ApiPermission(Permissions.AdminEventsManage)]
public async Task<IActionResult> StopEventConsumer(string consumerName)
{
@ -68,7 +69,7 @@ namespace Squidex.Areas.Api.Controllers.EventConsumers
[HttpPut]
[Route("event-consumers/{consumerName}/reset/")]
[ProducesResponseType(typeof(EventConsumerDto), 200)]
[ProducesResponseType(typeof(EventConsumerDto), StatusCodes.Status200OK)]
[ApiPermission(Permissions.AdminEventsManage)]
public async Task<IActionResult> ResetEventConsumer(string consumerName)
{

3
backend/src/Squidex/Areas/Api/Controllers/History/HistoryController.cs

@ -7,6 +7,7 @@
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Squidex.Areas.Api.Controllers.History.Models;
using Squidex.Domain.Apps.Entities.History;
@ -41,7 +42,7 @@ namespace Squidex.Areas.Api.Controllers.History
/// </returns>
[HttpGet]
[Route("apps/{app}/history/")]
[ProducesResponseType(typeof(HistoryEventDto), 200)]
[ProducesResponseType(typeof(HistoryEventDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppHistory)]
[ApiCosts(0.1)]
public async Task<IActionResult> GetHistory(string app, string channel)

3
backend/src/Squidex/Areas/Api/Controllers/Languages/LanguagesController.cs

@ -6,6 +6,7 @@
// ==========================================================================
using System.Linq;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Net.Http.Headers;
using Squidex.Infrastructure;
@ -36,7 +37,7 @@ namespace Squidex.Areas.Api.Controllers.Languages
/// </returns>
[HttpGet]
[Route("languages/")]
[ProducesResponseType(typeof(LanguageDto[]), 200)]
[ProducesResponseType(typeof(LanguageDto[]), StatusCodes.Status200OK)]
[ApiPermission]
public IActionResult GetLanguages()
{

3
backend/src/Squidex/Areas/Api/Controllers/News/NewsController.cs

@ -6,6 +6,7 @@
// ==========================================================================
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Squidex.Areas.Api.Controllers.News.Models;
using Squidex.Areas.Api.Controllers.News.Service;
@ -37,7 +38,7 @@ namespace Squidex.Areas.Api.Controllers.News
/// </returns>
[HttpGet]
[Route("news/features/")]
[ProducesResponseType(typeof(FeaturesDto), 200)]
[ProducesResponseType(typeof(FeaturesDto), StatusCodes.Status200OK)]
[ApiPermission]
public async Task<IActionResult> GetNews([FromQuery] int version = 0)
{

3
backend/src/Squidex/Areas/Api/Controllers/Ping/PingController.cs

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Squidex.Infrastructure.Commands;
using Squidex.Shared;
@ -33,7 +34,7 @@ namespace Squidex.Areas.Api.Controllers.Ping
/// 200 => Infos returned.
/// </returns>
[HttpGet]
[ProducesResponseType(typeof(ExposedValues), 200)]
[ProducesResponseType(typeof(ExposedValues), StatusCodes.Status200OK)]
[Route("info/")]
public IActionResult GetInfo()
{

5
backend/src/Squidex/Areas/Api/Controllers/Plans/AppPlansController.cs

@ -6,6 +6,7 @@
// ==========================================================================
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Net.Http.Headers;
using Squidex.Areas.Api.Controllers.Plans.Models;
@ -44,7 +45,7 @@ namespace Squidex.Areas.Api.Controllers.Plans
/// </returns>
[HttpGet]
[Route("apps/{app}/plans/")]
[ProducesResponseType(typeof(AppPlansDto), 200)]
[ProducesResponseType(typeof(AppPlansDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppPlansRead)]
[ApiCosts(0)]
public IActionResult GetPlans(string app)
@ -73,7 +74,7 @@ namespace Squidex.Areas.Api.Controllers.Plans
/// </returns>
[HttpPut]
[Route("apps/{app}/plan/")]
[ProducesResponseType(typeof(PlanChangedDto), 200)]
[ProducesResponseType(typeof(PlanChangedDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppPlansChange)]
[ApiCosts(0)]
public async Task<IActionResult> PutPlan(string app, [FromBody] ChangePlanDto request)

17
backend/src/Squidex/Areas/Api/Controllers/Rules/RulesController.cs

@ -8,6 +8,7 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Net.Http.Headers;
using NodaTime;
@ -56,7 +57,7 @@ namespace Squidex.Areas.Api.Controllers.Rules
/// </returns>
[HttpGet]
[Route("rules/actions/")]
[ProducesResponseType(typeof(Dictionary<string, RuleElementDto>), 200)]
[ProducesResponseType(typeof(Dictionary<string, RuleElementDto>), StatusCodes.Status200OK)]
[ApiPermission]
[ApiCosts(0)]
public IActionResult GetActions()
@ -83,7 +84,7 @@ namespace Squidex.Areas.Api.Controllers.Rules
/// </returns>
[HttpGet]
[Route("apps/{app}/rules/")]
[ProducesResponseType(typeof(RulesDto), 200)]
[ProducesResponseType(typeof(RulesDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppRulesRead)]
[ApiCosts(1)]
public async Task<IActionResult> GetRules(string app)
@ -131,7 +132,7 @@ namespace Squidex.Areas.Api.Controllers.Rules
/// </returns>
[HttpDelete]
[Route("apps/{app}/rules/run")]
[ProducesResponseType(204)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ApiPermissionOrAnonymous(Permissions.AppRulesEvents)]
[ApiCosts(1)]
public async Task<IActionResult> DeleteRuleRun(string app)
@ -154,7 +155,7 @@ namespace Squidex.Areas.Api.Controllers.Rules
/// </returns>
[HttpPut]
[Route("apps/{app}/rules/{id}/")]
[ProducesResponseType(typeof(RuleDto), 200)]
[ProducesResponseType(typeof(RuleDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppRulesUpdate)]
[ApiCosts(1)]
public async Task<IActionResult> PutRule(string app, DomainId id, [FromBody] UpdateRuleDto request)
@ -177,7 +178,7 @@ namespace Squidex.Areas.Api.Controllers.Rules
/// </returns>
[HttpPut]
[Route("apps/{app}/rules/{id}/enable/")]
[ProducesResponseType(typeof(RuleDto), 200)]
[ProducesResponseType(typeof(RuleDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppRulesDisable)]
[ApiCosts(1)]
public async Task<IActionResult> EnableRule(string app, DomainId id)
@ -200,7 +201,7 @@ namespace Squidex.Areas.Api.Controllers.Rules
/// </returns>
[HttpPut]
[Route("apps/{app}/rules/{id}/disable/")]
[ProducesResponseType(typeof(RuleDto), 200)]
[ProducesResponseType(typeof(RuleDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppRulesDisable)]
[ApiCosts(1)]
public async Task<IActionResult> DisableRule(string app, DomainId id)
@ -245,7 +246,7 @@ namespace Squidex.Areas.Api.Controllers.Rules
/// </returns>
[HttpPut]
[Route("apps/{app}/rules/{id}/run")]
[ProducesResponseType(204)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ApiPermissionOrAnonymous(Permissions.AppRulesEvents)]
[ApiCosts(1)]
public async Task<IActionResult> PutRuleRun(string app, DomainId id, [FromQuery] bool fromSnapshots = false)
@ -288,7 +289,7 @@ namespace Squidex.Areas.Api.Controllers.Rules
/// </returns>
[HttpGet]
[Route("apps/{app}/rules/events/")]
[ProducesResponseType(typeof(RuleEventsDto), 200)]
[ProducesResponseType(typeof(RuleEventsDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppRulesRead)]
[ApiCosts(0)]
public async Task<IActionResult> GetEvents(string app, [FromQuery] DomainId? ruleId = null, [FromQuery] int skip = 0, [FromQuery] int take = 20)

35
backend/src/Squidex/Areas/Api/Controllers/Schemas/SchemaFieldsController.cs

@ -6,6 +6,7 @@
// ==========================================================================
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Squidex.Areas.Api.Controllers.Schemas.Models;
using Squidex.Domain.Apps.Entities.Schemas;
@ -93,7 +94,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas
/// </returns>
[HttpPut]
[Route("apps/{app}/schemas/{name}/fields/ui/")]
[ProducesResponseType(typeof(SchemaDetailsDto), 200)]
[ProducesResponseType(typeof(SchemaDetailsDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppSchemasUpdate)]
[ApiCosts(1)]
public async Task<IActionResult> PutSchemaUIFields(string app, string name, [FromBody] ConfigureUIFieldsDto request)
@ -118,7 +119,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas
/// </returns>
[HttpPut]
[Route("apps/{app}/schemas/{name}/fields/ordering/")]
[ProducesResponseType(typeof(SchemaDetailsDto), 200)]
[ProducesResponseType(typeof(SchemaDetailsDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppSchemasUpdate)]
[ApiCosts(1)]
public async Task<IActionResult> PutSchemaFieldOrdering(string app, string name, [FromBody] ReorderFieldsDto request)
@ -144,7 +145,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas
/// </returns>
[HttpPut]
[Route("apps/{app}/schemas/{name}/fields/{parentId:long}/nested/ordering/")]
[ProducesResponseType(typeof(SchemaDetailsDto), 200)]
[ProducesResponseType(typeof(SchemaDetailsDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppSchemasUpdate)]
[ApiCosts(1)]
public async Task<IActionResult> PutNestedFieldOrdering(string app, string name, long parentId, [FromBody] ReorderFieldsDto request)
@ -170,7 +171,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas
/// </returns>
[HttpPut]
[Route("apps/{app}/schemas/{name}/fields/{id:long}/")]
[ProducesResponseType(typeof(SchemaDetailsDto), 200)]
[ProducesResponseType(typeof(SchemaDetailsDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppSchemasUpdate)]
[ApiCosts(1)]
public async Task<IActionResult> PutField(string app, string name, long id, [FromBody] UpdateFieldDto request)
@ -197,7 +198,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas
/// </returns>
[HttpPut]
[Route("apps/{app}/schemas/{name}/fields/{parentId:long}/nested/{id:long}/")]
[ProducesResponseType(typeof(SchemaDetailsDto), 200)]
[ProducesResponseType(typeof(SchemaDetailsDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppSchemasUpdate)]
[ApiCosts(1)]
public async Task<IActionResult> PutNestedField(string app, string name, long parentId, long id, [FromBody] UpdateFieldDto request)
@ -225,7 +226,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas
/// </remarks>
[HttpPut]
[Route("apps/{app}/schemas/{name}/fields/{id:long}/lock/")]
[ProducesResponseType(typeof(SchemaDetailsDto), 200)]
[ProducesResponseType(typeof(SchemaDetailsDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppSchemasUpdate)]
[ApiCosts(1)]
public async Task<IActionResult> LockField(string app, string name, long id)
@ -254,7 +255,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas
/// </remarks>
[HttpPut]
[Route("apps/{app}/schemas/{name}/fields/{parentId:long}/nested/{id:long}/lock/")]
[ProducesResponseType(typeof(SchemaDetailsDto), 200)]
[ProducesResponseType(typeof(SchemaDetailsDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppSchemasUpdate)]
[ApiCosts(1)]
public async Task<IActionResult> LockNestedField(string app, string name, long parentId, long id)
@ -282,7 +283,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas
/// </remarks>
[HttpPut]
[Route("apps/{app}/schemas/{name}/fields/{id:long}/hide/")]
[ProducesResponseType(typeof(SchemaDetailsDto), 200)]
[ProducesResponseType(typeof(SchemaDetailsDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppSchemasUpdate)]
[ApiCosts(1)]
public async Task<IActionResult> HideField(string app, string name, long id)
@ -311,7 +312,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas
/// </remarks>
[HttpPut]
[Route("apps/{app}/schemas/{name}/fields/{parentId:long}/nested/{id:long}/hide/")]
[ProducesResponseType(typeof(SchemaDetailsDto), 200)]
[ProducesResponseType(typeof(SchemaDetailsDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppSchemasUpdate)]
[ApiCosts(1)]
public async Task<IActionResult> HideNestedField(string app, string name, long parentId, long id)
@ -339,7 +340,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas
/// </remarks>
[HttpPut]
[Route("apps/{app}/schemas/{name}/fields/{id:long}/show/")]
[ProducesResponseType(typeof(SchemaDetailsDto), 200)]
[ProducesResponseType(typeof(SchemaDetailsDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppSchemasUpdate)]
[ApiCosts(1)]
public async Task<IActionResult> ShowField(string app, string name, long id)
@ -368,7 +369,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas
/// </remarks>
[HttpPut]
[Route("apps/{app}/schemas/{name}/fields/{parentId:long}/nested/{id:long}/show/")]
[ProducesResponseType(typeof(SchemaDetailsDto), 200)]
[ProducesResponseType(typeof(SchemaDetailsDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppSchemasUpdate)]
[ApiCosts(1)]
public async Task<IActionResult> ShowNestedField(string app, string name, long parentId, long id)
@ -396,7 +397,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas
/// </remarks>
[HttpPut]
[Route("apps/{app}/schemas/{name}/fields/{id:long}/enable/")]
[ProducesResponseType(typeof(SchemaDetailsDto), 200)]
[ProducesResponseType(typeof(SchemaDetailsDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppSchemasUpdate)]
[ApiCosts(1)]
public async Task<IActionResult> EnableField(string app, string name, long id)
@ -425,7 +426,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas
/// </remarks>
[HttpPut]
[Route("apps/{app}/schemas/{name}/fields/{parentId:long}/nested/{id:long}/enable/")]
[ProducesResponseType(typeof(SchemaDetailsDto), 200)]
[ProducesResponseType(typeof(SchemaDetailsDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppSchemasUpdate)]
[ApiCosts(1)]
public async Task<IActionResult> EnableNestedField(string app, string name, long parentId, long id)
@ -453,7 +454,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas
/// </remarks>
[HttpPut]
[Route("apps/{app}/schemas/{name}/fields/{id:long}/disable/")]
[ProducesResponseType(typeof(SchemaDetailsDto), 200)]
[ProducesResponseType(typeof(SchemaDetailsDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppSchemasUpdate)]
[ApiCosts(1)]
public async Task<IActionResult> DisableField(string app, string name, long id)
@ -482,7 +483,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas
/// </remarks>
[HttpPut]
[Route("apps/{app}/schemas/{name}/fields/{parentId:long}/nested/{id:long}/disable/")]
[ProducesResponseType(typeof(SchemaDetailsDto), 200)]
[ProducesResponseType(typeof(SchemaDetailsDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppSchemasUpdate)]
[ApiCosts(1)]
public async Task<IActionResult> DisableNestedField(string app, string name, long parentId, long id)
@ -507,7 +508,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas
/// </returns>
[HttpDelete]
[Route("apps/{app}/schemas/{name}/fields/{id:long}/")]
[ProducesResponseType(typeof(SchemaDetailsDto), 200)]
[ProducesResponseType(typeof(SchemaDetailsDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppSchemasUpdate)]
[ApiCosts(1)]
public async Task<IActionResult> DeleteField(string app, string name, long id)
@ -533,7 +534,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas
/// </returns>
[HttpDelete]
[Route("apps/{app}/schemas/{name}/fields/{parentId:long}/nested/{id:long}/")]
[ProducesResponseType(typeof(SchemaDetailsDto), 200)]
[ProducesResponseType(typeof(SchemaDetailsDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppSchemasUpdate)]
[ApiCosts(1)]
public async Task<IActionResult> DeleteNestedField(string app, string name, long parentId, long id)

21
backend/src/Squidex/Areas/Api/Controllers/Schemas/SchemasController.cs

@ -7,6 +7,7 @@
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Net.Http.Headers;
using Squidex.Areas.Api.Controllers.Schemas.Models;
@ -44,7 +45,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas
/// </returns>
[HttpGet]
[Route("apps/{app}/schemas/")]
[ProducesResponseType(typeof(SchemasDto), 200)]
[ProducesResponseType(typeof(SchemasDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppSchemasRead)]
[ApiCosts(0)]
public async Task<IActionResult> GetSchemas(string app)
@ -72,7 +73,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas
/// </returns>
[HttpGet]
[Route("apps/{app}/schemas/{name}/")]
[ProducesResponseType(typeof(SchemaDetailsDto), 200)]
[ProducesResponseType(typeof(SchemaDetailsDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppSchemasRead)]
[ApiCosts(0)]
public async Task<IActionResult> GetSchema(string app, string name)
@ -142,7 +143,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas
/// </returns>
[HttpPut]
[Route("apps/{app}/schemas/{name}/")]
[ProducesResponseType(typeof(SchemaDetailsDto), 200)]
[ProducesResponseType(typeof(SchemaDetailsDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppSchemasUpdate)]
[ApiCosts(1)]
public async Task<IActionResult> PutSchema(string app, string name, [FromBody] UpdateSchemaDto request)
@ -167,7 +168,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas
/// </returns>
[HttpPut]
[Route("apps/{app}/schemas/{name}/sync")]
[ProducesResponseType(typeof(SchemaDetailsDto), 200)]
[ProducesResponseType(typeof(SchemaDetailsDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppSchemasUpdate)]
[ApiCosts(1)]
public async Task<IActionResult> PutSchemaSync(string app, string name, [FromBody] SynchronizeSchemaDto request)
@ -192,7 +193,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas
/// </returns>
[HttpPut]
[Route("apps/{app}/schemas/{name}/category")]
[ProducesResponseType(typeof(SchemaDetailsDto), 200)]
[ProducesResponseType(typeof(SchemaDetailsDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppSchemasUpdate)]
[ApiCosts(1)]
public async Task<IActionResult> PutCategory(string app, string name, [FromBody] ChangeCategoryDto request)
@ -217,7 +218,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas
/// </returns>
[HttpPut]
[Route("apps/{app}/schemas/{name}/preview-urls")]
[ProducesResponseType(typeof(SchemaDetailsDto), 200)]
[ProducesResponseType(typeof(SchemaDetailsDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppSchemasUpdate)]
[ApiCosts(1)]
public async Task<IActionResult> PutPreviewUrls(string app, string name, [FromBody] ConfigurePreviewUrlsDto request)
@ -242,7 +243,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas
/// </returns>
[HttpPut]
[Route("apps/{app}/schemas/{name}/scripts/")]
[ProducesResponseType(typeof(SchemaDetailsDto), 200)]
[ProducesResponseType(typeof(SchemaDetailsDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppSchemasScripts)]
[ApiCosts(1)]
public async Task<IActionResult> PutScripts(string app, string name, [FromBody] SchemaScriptsDto request)
@ -267,7 +268,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas
/// </returns>
[HttpPut]
[Route("apps/{app}/schemas/{name}/rules/")]
[ProducesResponseType(typeof(SchemaDetailsDto), 200)]
[ProducesResponseType(typeof(SchemaDetailsDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppSchemasUpdate)]
[ApiCosts(1)]
public async Task<IActionResult> PutRules(string app, string name, [FromBody] ConfigureFieldRulesDto request)
@ -290,7 +291,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas
/// </returns>
[HttpPut]
[Route("apps/{app}/schemas/{name}/publish/")]
[ProducesResponseType(typeof(SchemaDetailsDto), 200)]
[ProducesResponseType(typeof(SchemaDetailsDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppSchemasPublish)]
[ApiCosts(1)]
public async Task<IActionResult> PublishSchema(string app, string name)
@ -313,7 +314,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas
/// </returns>
[HttpPut]
[Route("apps/{app}/schemas/{name}/unpublish/")]
[ProducesResponseType(typeof(SchemaDetailsDto), 200)]
[ProducesResponseType(typeof(SchemaDetailsDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppSchemasPublish)]
[ApiCosts(1)]
public async Task<IActionResult> UnpublishSchema(string app, string name)

3
backend/src/Squidex/Areas/Api/Controllers/Search/SearchController.cs

@ -7,6 +7,7 @@
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Squidex.Areas.Api.Controllers.Search.Models;
using Squidex.Domain.Apps.Entities.Search;
@ -41,7 +42,7 @@ namespace Squidex.Areas.Api.Controllers.Search
/// </returns>
[HttpGet]
[Route("apps/{app}/search/")]
[ProducesResponseType(typeof(SearchResultDto[]), 200)]
[ProducesResponseType(typeof(SearchResultDto[]), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppSearch)]
[ApiCosts(0)]
public async Task<IActionResult> GetSearchResults(string app, [FromQuery] string? query = null)

9
backend/src/Squidex/Areas/Api/Controllers/Statistics/UsagesController.cs

@ -9,6 +9,7 @@ using System;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Squidex.Areas.Api.Controllers.Statistics.Models;
using Squidex.Domain.Apps.Entities.Apps;
@ -66,7 +67,7 @@ namespace Squidex.Areas.Api.Controllers.Statistics
/// </returns>
[HttpGet]
[Route("apps/{app}/usages/log/")]
[ProducesResponseType(typeof(LogDownloadDto), 200)]
[ProducesResponseType(typeof(LogDownloadDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppUsage)]
[ApiCosts(0)]
public IActionResult GetLog(string app)
@ -93,7 +94,7 @@ namespace Squidex.Areas.Api.Controllers.Statistics
/// </returns>
[HttpGet]
[Route("apps/{app}/usages/calls/{fromDate}/{toDate}/")]
[ProducesResponseType(typeof(CallsUsageDtoDto), 200)]
[ProducesResponseType(typeof(CallsUsageDtoDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppUsage)]
[ApiCosts(0)]
public async Task<IActionResult> GetUsages(string app, DateTime fromDate, DateTime toDate)
@ -122,7 +123,7 @@ namespace Squidex.Areas.Api.Controllers.Statistics
/// </returns>
[HttpGet]
[Route("apps/{app}/usages/storage/today/")]
[ProducesResponseType(typeof(CurrentStorageDto), 200)]
[ProducesResponseType(typeof(CurrentStorageDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppUsage)]
[ApiCosts(0)]
public async Task<IActionResult> GetCurrentStorageSize(string app)
@ -149,7 +150,7 @@ namespace Squidex.Areas.Api.Controllers.Statistics
/// </returns>
[HttpGet]
[Route("apps/{app}/usages/storage/{fromDate}/{toDate}/")]
[ProducesResponseType(typeof(StorageUsagePerDateDto[]), 200)]
[ProducesResponseType(typeof(StorageUsagePerDateDto[]), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppUsage)]
[ApiCosts(0)]
public async Task<IActionResult> GetStorageSizes(string app, DateTime fromDate, DateTime toDate)

3
backend/src/Squidex/Areas/Api/Controllers/Translations/TranslationsController.cs

@ -6,6 +6,7 @@
// ==========================================================================
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Squidex.Areas.Api.Controllers.Translations.Models;
using Squidex.Infrastructure.Commands;
@ -39,7 +40,7 @@ namespace Squidex.Areas.Api.Controllers.Translations
/// </returns>
[HttpPost]
[Route("apps/{app}/translations/")]
[ProducesResponseType(typeof(TranslationDto), 200)]
[ProducesResponseType(typeof(TranslationDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppTranslate)]
[ApiCosts(0)]
public async Task<IActionResult> PostTranslation(string app, [FromBody] TranslateDto request)

7
backend/src/Squidex/Areas/Api/Controllers/UI/UIController.cs

@ -7,6 +7,7 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using Squidex.Areas.Api.Controllers.UI.Models;
@ -42,7 +43,7 @@ namespace Squidex.Areas.Api.Controllers.UI
/// </returns>
[HttpGet]
[Route("ui/settings/")]
[ProducesResponseType(typeof(UISettingsDto), 200)]
[ProducesResponseType(typeof(UISettingsDto), StatusCodes.Status200OK)]
[ApiPermission]
public IActionResult GetSettings()
{
@ -64,7 +65,7 @@ namespace Squidex.Areas.Api.Controllers.UI
/// </returns>
[HttpGet]
[Route("apps/{app}/ui/settings/")]
[ProducesResponseType(typeof(Dictionary<string, string>), 200)]
[ProducesResponseType(typeof(Dictionary<string, string>), StatusCodes.Status200OK)]
[ApiPermission]
public async Task<IActionResult> GetSettings(string app)
{
@ -83,7 +84,7 @@ namespace Squidex.Areas.Api.Controllers.UI
/// </returns>
[HttpGet]
[Route("apps/{app}/ui/settings/me")]
[ProducesResponseType(typeof(Dictionary<string, string>), 200)]
[ProducesResponseType(typeof(Dictionary<string, string>), StatusCodes.Status200OK)]
[ApiPermission]
public async Task<IActionResult> GetUserSettings(string app)
{

7
backend/src/Squidex/Areas/Api/Controllers/Users/Models/UserDto.cs

@ -8,6 +8,7 @@
using System.Collections.Generic;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.Validation;
using Squidex.Shared.Identity;
using Squidex.Shared.Users;
using Squidex.Web;
@ -47,8 +48,8 @@ namespace Squidex.Areas.Api.Controllers.Users.Models
public static UserDto FromUser(IUser user, Resources resources)
{
var userPermssions = user.Permissions().ToIds();
var userName = user.DisplayName()!;
var userPermssions = user.Claims.Permissions().ToIds();
var userName = user.Claims.DisplayName()!;
var result = SimpleMapper.Map(user, new UserDto { DisplayName = userName, Permissions = userPermssions });
@ -79,6 +80,8 @@ namespace Squidex.Areas.Api.Controllers.Users.Models
{
AddPutLink("unlock", resources.Url<UserManagementController>(c => nameof(c.UnlockUser), values));
}
AddDeleteLink("delete", resources.Url<UserManagementController>(x => nameof(x.DeleteUser), values));
}
if (resources.CanUpdateUser)

4
backend/src/Squidex/Areas/Api/Controllers/Users/Models/UsersDto.cs

@ -7,8 +7,8 @@
using System.Collections.Generic;
using System.Linq;
using Squidex.Domain.Users;
using Squidex.Infrastructure.Validation;
using Squidex.Shared.Users;
using Squidex.Web;
namespace Squidex.Areas.Api.Controllers.Users.Models
@ -26,7 +26,7 @@ namespace Squidex.Areas.Api.Controllers.Users.Models
[LocalizedRequired]
public UserDto[] Items { get; set; }
public static UsersDto FromResults(IEnumerable<UserWithClaims> items, long total, Resources resources)
public static UsersDto FromResults(IEnumerable<IUser> items, long total, Resources resources)
{
var result = new UsersDto
{

51
backend/src/Squidex/Areas/Api/Controllers/Users/UserManagementController.cs

@ -6,13 +6,12 @@
// ==========================================================================
using System.Threading.Tasks;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Squidex.Areas.Api.Controllers.Users.Models;
using Squidex.Domain.Users;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.Tasks;
using Squidex.Infrastructure.Translations;
using Squidex.Shared;
using Squidex.Web;
@ -22,29 +21,23 @@ namespace Squidex.Areas.Api.Controllers.Users
[ApiModelValidation(true)]
public sealed class UserManagementController : ApiController
{
private readonly UserManager<IdentityUser> userManager;
private readonly IUserFactory userFactory;
private readonly IUserEvents userEvents;
private readonly IUserService userService;
public UserManagementController(ICommandBus commandBus, UserManager<IdentityUser> userManager, IUserFactory userFactory, IUserEvents userEvents)
public UserManagementController(ICommandBus commandBus, IUserService userService)
: base(commandBus)
{
this.userManager = userManager;
this.userFactory = userFactory;
this.userEvents = userEvents;
this.userService = userService;
}
[HttpGet]
[Route("user-management/")]
[ProducesResponseType(typeof(UsersDto), 200)]
[ProducesResponseType(typeof(UsersDto), StatusCodes.Status200OK)]
[ApiPermission(Permissions.AdminUsersRead)]
public async Task<IActionResult> GetUsers([FromQuery] string? query = null, [FromQuery] int skip = 0, [FromQuery] int take = 10)
{
var (items, total) = await AsyncHelper.WhenAll(
userManager.QueryByEmailAsync(query, take, skip),
userManager.CountByEmailAsync(query));
var users = await userService.QueryAsync(query, take, skip);
var response = UsersDto.FromResults(items, total, Resources);
var response = UsersDto.FromResults(users, users.Total, Resources);
return Ok(response);
}
@ -55,7 +48,7 @@ namespace Squidex.Areas.Api.Controllers.Users
[ApiPermission(Permissions.AdminUsersRead)]
public async Task<IActionResult> GetUser(string id)
{
var user = await userManager.FindByIdWithClaimsAsync(id);
var user = await userService.FindByIdAsync(id);
if (user == null)
{
@ -73,9 +66,7 @@ namespace Squidex.Areas.Api.Controllers.Users
[ApiPermission(Permissions.AdminUsersCreate)]
public async Task<IActionResult> PostUser([FromBody] CreateUserDto request)
{
var user = await userManager.CreateAsync(userFactory, request.ToValues());
userEvents.OnUserRegistered(user);
var user = await userService.CreateAsync(request.Email, request.ToValues());
var response = UserDto.FromUser(user, Resources);
@ -88,9 +79,7 @@ namespace Squidex.Areas.Api.Controllers.Users
[ApiPermission(Permissions.AdminUsersUpdate)]
public async Task<IActionResult> PutUser(string id, [FromBody] UpdateUserDto request)
{
var user = await userManager.UpdateAsync(id, request.ToValues());
userEvents.OnUserUpdated(user);
var user = await userService.UpdateAsync(id, request.ToValues());
var response = UserDto.FromUser(user, Resources);
@ -108,7 +97,7 @@ namespace Squidex.Areas.Api.Controllers.Users
throw new DomainForbiddenException(T.Get("users.lockYourselfError"));
}
var user = await userManager.LockAsync(id);
var user = await userService.LockAsync(id);
var response = UserDto.FromUser(user, Resources);
@ -126,11 +115,27 @@ namespace Squidex.Areas.Api.Controllers.Users
throw new DomainForbiddenException(T.Get("users.unlockYourselfError"));
}
var user = await userManager.UnlockAsync(id);
var user = await userService.UnlockAsync(id);
var response = UserDto.FromUser(user, Resources);
return Ok(response);
}
[HttpDelete]
[Route("user-management/{id}/")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ApiPermission(Permissions.AdminUsersUnlock)]
public async Task<IActionResult> DeleteUser(string id)
{
if (this.IsUser(id))
{
throw new DomainForbiddenException(T.Get("users.deleteYourselfError"));
}
await userService.DeleteAsync(id);
return NoContent();
}
}
}

16
backend/src/Squidex/Areas/Api/Controllers/Users/UsersController.cs

@ -10,12 +10,14 @@ using System.IO;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Net.Http.Headers;
using Squidex.Areas.Api.Controllers.Users.Models;
using Squidex.Domain.Users;
using Squidex.Infrastructure.Commands;
using Squidex.Log;
using Squidex.Shared.Identity;
using Squidex.Shared.Users;
using Squidex.Web;
@ -68,7 +70,7 @@ namespace Squidex.Areas.Api.Controllers.Users
/// </returns>
[HttpGet]
[Route("/")]
[ProducesResponseType(typeof(ResourcesDto), 200)]
[ProducesResponseType(typeof(ResourcesDto), StatusCodes.Status200OK)]
[ApiPermission]
public IActionResult GetUserResources()
{
@ -89,7 +91,7 @@ namespace Squidex.Areas.Api.Controllers.Users
/// </returns>
[HttpGet]
[Route("users/")]
[ProducesResponseType(typeof(UserDto[]), 200)]
[ProducesResponseType(typeof(UserDto[]), StatusCodes.Status200OK)]
[ApiPermission]
public async Task<IActionResult> GetUsers(string query)
{
@ -97,7 +99,7 @@ namespace Squidex.Areas.Api.Controllers.Users
{
var users = await userResolver.QueryByEmailAsync(query);
var response = users.Where(x => !x.IsHidden()).Select(x => UserDto.FromUser(x, Resources)).ToArray();
var response = users.Where(x => !x.Claims.IsHidden()).Select(x => UserDto.FromUser(x, Resources)).ToArray();
return Ok(response);
}
@ -121,7 +123,7 @@ namespace Squidex.Areas.Api.Controllers.Users
/// </returns>
[HttpGet]
[Route("users/{id}/")]
[ProducesResponseType(typeof(UserDto), 200)]
[ProducesResponseType(typeof(UserDto), StatusCodes.Status200OK)]
[ApiPermission]
public async Task<IActionResult> GetUser(string id)
{
@ -156,7 +158,7 @@ namespace Squidex.Areas.Api.Controllers.Users
/// </returns>
[HttpGet]
[Route("users/{id}/picture/")]
[ProducesResponseType(typeof(FileResult), 200)]
[ProducesResponseType(typeof(FileResult), StatusCodes.Status200OK)]
[ResponseCache(Duration = 300)]
public async Task<IActionResult> GetUserPicture(string id)
{
@ -166,7 +168,7 @@ namespace Squidex.Areas.Api.Controllers.Users
if (entity != null)
{
if (entity.IsPictureUrlStored())
if (entity.Claims.IsPictureUrlStored())
{
var callback = new FileCallback(async (body, range, ct) =>
{
@ -185,7 +187,7 @@ namespace Squidex.Areas.Api.Controllers.Users
using (var client = httpClientFactory.CreateClient())
{
var url = entity.PictureNormalizedUrl();
var url = entity.Claims.PictureNormalizedUrl();
if (!string.IsNullOrWhiteSpace(url))
{

24
backend/src/Squidex/Areas/IdentityServer/Config/CreateAdminInitializer.cs

@ -6,10 +6,8 @@
// ==========================================================================
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Logging;
@ -20,7 +18,7 @@ using Squidex.Infrastructure;
using Squidex.Infrastructure.Security;
using Squidex.Log;
using Squidex.Shared;
using Squidex.Shared.Users;
using Squidex.Shared.Identity;
namespace Squidex.Areas.IdentityServer.Config
{
@ -46,25 +44,24 @@ namespace Squidex.Areas.IdentityServer.Config
{
using (var scope = serviceProvider.CreateScope())
{
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<IdentityUser>>();
var userFactory = scope.ServiceProvider.GetRequiredService<IUserFactory>();
var userService = scope.ServiceProvider.GetRequiredService<IUserService>();
var adminEmail = identityOptions.AdminEmail;
var adminPass = identityOptions.AdminPassword;
var isEmpty = IsEmpty(userManager);
var isEmpty = await IsEmptyAsync(userService);
if (isEmpty || identityOptions.AdminRecreate)
{
try
{
var user = await userManager.FindByEmailWithClaimsAsync(adminEmail);
var user = await userService.FindByIdAsync(adminEmail);
if (user != null)
{
if (identityOptions.AdminRecreate)
{
var permissions = CreatePermissions(user.Permissions());
var permissions = CreatePermissions(user.Claims.Permissions());
var values = new UserValues
{
@ -72,7 +69,7 @@ namespace Squidex.Areas.IdentityServer.Config
Permissions = permissions
};
await userManager.UpdateAsync(user.Identity, values);
await userService.UpdateAsync(user.Id, values);
}
}
else
@ -81,13 +78,12 @@ namespace Squidex.Areas.IdentityServer.Config
var values = new UserValues
{
Email = adminEmail,
Password = adminPass,
Permissions = permissions,
DisplayName = adminEmail
};
await userManager.CreateAsync(userFactory, values);
await userService.CreateAsync(adminEmail, values);
}
}
catch (Exception ex)
@ -115,9 +111,11 @@ namespace Squidex.Areas.IdentityServer.Config
return permissions;
}
private static bool IsEmpty(UserManager<IdentityUser> userManager)
private static async Task<bool> IsEmptyAsync(IUserService userService)
{
return userManager.SupportsQueryableUsers && !userManager.Users.Any();
var users = await userService.QueryAsync(take: 0);
return users.Total == 0;
}
}
}

57
backend/src/Squidex/Areas/IdentityServer/Config/IdentityServerExtensions.cs

@ -5,19 +5,7 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Linq;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Logging;
using Squidex.Config;
using Squidex.Domain.Users;
using Squidex.Infrastructure.Security;
using Squidex.Infrastructure.Tasks;
using Squidex.Log;
using Squidex.Shared;
namespace Squidex.Areas.IdentityServer.Config
{
@ -29,50 +17,5 @@ namespace Squidex.Areas.IdentityServer.Config
return app;
}
public static IServiceProvider UseSquidexAdmin(this IServiceProvider services)
{
var options = services.GetRequiredService<IOptions<MyIdentityOptions>>().Value;
IdentityModelEventSource.ShowPII = options.ShowPII;
var userManager = services.GetRequiredService<UserManager<IdentityUser>>();
var userFactory = services.GetRequiredService<IUserFactory>();
var log = services.GetRequiredService<ISemanticLog>();
if (options.IsAdminConfigured())
{
var adminEmail = options.AdminEmail;
var adminPass = options.AdminPassword;
AsyncHelper.Sync(async () =>
{
if (userManager.SupportsQueryableUsers && !userManager.Users.Any())
{
try
{
var values = new UserValues
{
Email = adminEmail,
Password = adminPass,
Permissions = new PermissionSet(Permissions.Admin),
DisplayName = adminEmail
};
await userManager.CreateAsync(userFactory, values);
}
catch (Exception ex)
{
log.LogError(ex, w => w
.WriteProperty("action", "createAdmin")
.WriteProperty("status", "failed"));
}
}
});
}
return services;
}
}
}

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

@ -43,6 +43,9 @@ namespace Squidex.Areas.IdentityServer.Config
services.AddSingletonAs<DefaultKeyStore>()
.As<ISigningCredentialStore>().As<IValidationKeysStore>();
services.AddScopedAs<DefaultUserService>()
.As<IUserService>();
services.AddSingletonAs<PwnedPasswordValidator>()
.As<IPasswordValidator<IdentityUser>>();

20
backend/src/Squidex/Areas/IdentityServer/Config/LazyClientStore.cs

@ -11,7 +11,6 @@ using System.Threading.Tasks;
using IdentityServer4;
using IdentityServer4.Models;
using IdentityServer4.Stores;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Squidex.Config;
@ -69,20 +68,27 @@ namespace Squidex.Areas.IdentityServer.Config
using (var scope = serviceProvider.CreateScope())
{
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<IdentityUser>>();
var userService = scope.ServiceProvider.GetRequiredService<IUserService>();
var user = await userManager.FindByIdWithClaimsAsync(clientId);
var user = await userService.FindByIdAsync(clientId);
if (!string.IsNullOrWhiteSpace(user?.ClientSecret()))
if (user == null)
{
return CreateClientFromUser(user);
return null;
}
var secret = user.Claims.ClientSecret();
if (!string.IsNullOrWhiteSpace(secret))
{
return CreateClientFromUser(user, secret);
}
}
return null;
}
private static Client CreateClientFromUser(UserWithClaims user)
private static Client CreateClientFromUser(IUser user, string secret)
{
return new Client
{
@ -91,7 +97,7 @@ namespace Squidex.Areas.IdentityServer.Config
ClientClaimsPrefix = null,
ClientSecrets = new List<Secret>
{
new Secret(user.ClientSecret().Sha256())
new Secret(secret.Sha256())
},
AccessTokenLifetime = (int)TimeSpan.FromDays(30).TotalSeconds,
AllowedGrantTypes = GrantTypes.ClientCredentials,

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

@ -6,11 +6,7 @@
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Security.Claims;
using System.Text;
using System.Threading.Tasks;
using IdentityModel;
using IdentityServer4;
@ -27,21 +23,16 @@ using Squidex.Infrastructure;
using Squidex.Infrastructure.Security;
using Squidex.Infrastructure.Translations;
using Squidex.Log;
using Squidex.Shared;
using Squidex.Shared.Identity;
using Squidex.Shared.Users;
using Squidex.Web;
#pragma warning disable CA1827 // Do not use Count() or LongCount() when Any() can be used
namespace Squidex.Areas.IdentityServer.Controllers.Account
{
public sealed class AccountController : IdentityServerController
{
private readonly SignInManager<IdentityUser> signInManager;
private readonly UserManager<IdentityUser> userManager;
private readonly IUserFactory userFactory;
private readonly IUserEvents userEvents;
private readonly IUserService userService;
private readonly IUrlGenerator urlGenerator;
private readonly MyIdentityOptions identityOptions;
private readonly ISemanticLog log;
@ -49,9 +40,7 @@ namespace Squidex.Areas.IdentityServer.Controllers.Account
public AccountController(
SignInManager<IdentityUser> signInManager,
UserManager<IdentityUser> userManager,
IUserFactory userFactory,
IUserEvents userEvents,
IUserService userService,
IUrlGenerator urlGenerator,
IOptions<MyIdentityOptions> identityOptions,
ISemanticLog log,
@ -61,9 +50,7 @@ namespace Squidex.Areas.IdentityServer.Controllers.Account
this.interactions = interactions;
this.signInManager = signInManager;
this.urlGenerator = urlGenerator;
this.userEvents = userEvents;
this.userFactory = userFactory;
this.userManager = userManager;
this.userService = userService;
this.log = log;
}
@ -130,7 +117,7 @@ namespace Squidex.Areas.IdentityServer.Controllers.Account
return View(vm);
}
var user = await userManager.GetUserWithClaimsAsync(User);
var user = await userService.GetAsync(User);
if (user == null)
{
@ -143,9 +130,7 @@ namespace Squidex.Areas.IdentityServer.Controllers.Account
ConsentForEmails = model.ConsentToAutomatedEmails
};
await userManager.UpdateAsync(user.Id, update);
userEvents.OnConsentGiven(user);
await userService.UpdateAsync(user.Id, update);
return RedirectToReturnUrl(returnUrl);
}
@ -286,11 +271,11 @@ namespace Squidex.Areas.IdentityServer.Controllers.Account
var isLoggedIn = result.Succeeded;
UserWithClaims? user;
IUser? user;
if (isLoggedIn)
{
user = await userManager.FindByLoginWithClaimsAsync(externalLogin.LoginProvider, externalLogin.ProviderKey);
user = await userService.FindByLoginAsync(externalLogin.LoginProvider, externalLogin.ProviderKey);
}
else
{
@ -301,42 +286,38 @@ namespace Squidex.Areas.IdentityServer.Controllers.Account
throw new DomainException("User has no exposed email address.");
}
user = await userManager.FindByEmailWithClaimsAsync(email);
user = await userService.FindByEmailAsync(email);
if (user != null)
{
isLoggedIn =
await AddLoginAsync(user, externalLogin) &&
await AddClaimsAsync(user, externalLogin, email) &&
await LoginAsync(externalLogin);
var update = CreateUserValues(externalLogin, email, user: user);
await userService.UpdateAsync(user.Id, update);
}
else
{
user = new UserWithClaims(userFactory.Create(email), new List<Claim>());
var update = CreateUserValues(externalLogin, email);
var isFirst = userManager.Users.LongCount() == 0;
user = await userService.CreateAsync(email, update, identityOptions.LockAutomatically);
}
isLoggedIn =
await AddUserAsync(user) &&
await AddLoginAsync(user, externalLogin) &&
await AddClaimsAsync(user, externalLogin, email, isFirst) &&
await LockAsync(user, isFirst) &&
await LoginAsync(externalLogin);
await userService.AddLoginAsync(user.Id, externalLogin);
userEvents.OnUserRegistered(user);
var (success, locked) = await LoginAsync(externalLogin);
if (await userManager.IsLockedOutAsync(user.Identity))
if (locked)
{
return View(nameof(LockedOut));
}
}
isLoggedIn = success;
}
if (!isLoggedIn)
{
return RedirectToAction(nameof(Login));
}
else if (user != null && !user.HasConsent() && !identityOptions.NoConsent)
else if (user != null && !user.Claims.HasConsent() && !identityOptions.NoConsent)
{
return RedirectToAction(nameof(Consent), new { returnUrl });
}
@ -346,56 +327,31 @@ namespace Squidex.Areas.IdentityServer.Controllers.Account
}
}
private Task<bool> AddLoginAsync(UserWithClaims user, UserLoginInfo externalLogin)
private static UserValues CreateUserValues(ExternalLoginInfo externalLogin, string email, IUser? user = null)
{
return MakeIdentityOperation(() => userManager.AddLoginAsync(user.Identity, externalLogin));
}
private Task<bool> AddUserAsync(UserWithClaims user)
var values = new UserValues
{
return MakeIdentityOperation(() => userManager.CreateAsync(user.Identity));
}
CustomClaims = externalLogin.Principal.Claims.GetSquidexClaims().ToList()
};
private async Task<bool> LoginAsync(UserLoginInfo externalLogin)
if (user != null && !user.Claims.HasPictureUrl())
{
var result = await signInManager.ExternalLoginSignInAsync(externalLogin.LoginProvider, externalLogin.ProviderKey, true);
return result.Succeeded;
values.PictureUrl = GravatarHelper.CreatePictureUrl(email);
}
private Task<bool> LockAsync(UserWithClaims user, bool isFirst)
{
if (isFirst || !identityOptions.LockAutomatically)
if (user != null && !user.Claims.HasDisplayName())
{
return Task.FromResult(true);
values.DisplayName = email;
}
return MakeIdentityOperation(() => userManager.SetLockoutEndDateAsync(user.Identity, DateTimeOffset.UtcNow.AddYears(100)));
return values;
}
private async Task<bool> AddClaimsAsync(UserWithClaims user, ExternalLoginInfo externalLogin, string email, bool isFirst = false)
{
var update = new UserValues
private async Task<(bool Success, bool Locked)> LoginAsync(UserLoginInfo externalLogin)
{
CustomClaims = externalLogin.Principal.GetSquidexClaims().ToList()
};
if (!user.HasPictureUrl())
{
update.PictureUrl = GravatarHelper.CreatePictureUrl(email);
}
if (!user.HasDisplayName())
{
update.DisplayName = email;
}
if (isFirst)
{
update.Permissions = new PermissionSet(Permissions.Admin);
}
var result = await signInManager.ExternalLoginSignInAsync(externalLogin.LoginProvider, externalLogin.ProviderKey, true);
return await MakeIdentityOperation(() => userManager.SyncClaims(user.Identity, update));
return (result.Succeeded, result.IsLockedOut);
}
private IActionResult RedirectToLogoutUrl(LogoutRequest context)
@ -421,42 +377,5 @@ namespace Squidex.Areas.IdentityServer.Controllers.Account
return Redirect("~/../");
}
}
private async Task<bool> MakeIdentityOperation(Func<Task<IdentityResult>> action, [CallerMemberName] string? operationName = null)
{
try
{
var result = await action();
if (!result.Succeeded)
{
var errorMessageBuilder = new StringBuilder();
foreach (var error in result.Errors)
{
errorMessageBuilder.Append(error.Code);
errorMessageBuilder.Append(": ");
errorMessageBuilder.AppendLine(error.Description);
}
var errorMessage = errorMessageBuilder.ToString();
log.LogError((operationName, errorMessage), (ctx, w) => w
.WriteProperty("action", ctx.operationName)
.WriteProperty("status", "Failed")
.WriteProperty("message", ctx.errorMessage));
}
return result.Succeeded;
}
catch (Exception ex)
{
log.LogError(ex, operationName, (logOperationName, w) => w
.WriteProperty("action", logOperationName)
.WriteProperty("status", "Failed"));
return false;
}
}
}
}

103
backend/src/Squidex/Areas/IdentityServer/Controllers/Profile/ProfileController.cs

@ -23,6 +23,7 @@ using Squidex.Infrastructure;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.Tasks;
using Squidex.Infrastructure.Translations;
using Squidex.Infrastructure.Validation;
using Squidex.Shared.Identity;
using Squidex.Shared.Users;
@ -33,25 +34,22 @@ namespace Squidex.Areas.IdentityServer.Controllers.Profile
{
private static readonly ResizeOptions ResizeOptions = new ResizeOptions { Width = 128, Height = 128, Mode = ResizeMode.Crop };
private readonly SignInManager<IdentityUser> signInManager;
private readonly UserManager<IdentityUser> userManager;
private readonly IUserPictureStore userPictureStore;
private readonly IUserEvents userEvents;
private readonly IUserService userService;
private readonly IAssetThumbnailGenerator assetThumbnailGenerator;
private readonly MyIdentityOptions identityOptions;
public ProfileController(
SignInManager<IdentityUser> signInManager,
UserManager<IdentityUser> userManager,
IUserPictureStore userPictureStore,
IUserEvents userEvents,
IUserService userService,
IAssetThumbnailGenerator assetThumbnailGenerator,
IOptions<MyIdentityOptions> identityOptions)
{
this.signInManager = signInManager;
this.identityOptions = identityOptions.Value;
this.userManager = userManager;
this.userPictureStore = userPictureStore;
this.userEvents = userEvents;
this.userService = userService;
this.assetThumbnailGenerator = assetThumbnailGenerator;
}
@ -59,7 +57,7 @@ namespace Squidex.Areas.IdentityServer.Controllers.Profile
[Route("/account/profile/")]
public async Task<IActionResult> Profile(string? successMessage = null)
{
var user = await userManager.GetUserWithClaimsAsync(User);
var user = await userService.GetAsync(User);
return View(await GetProfileVM<None>(user, successMessage: successMessage));
}
@ -72,7 +70,7 @@ namespace Squidex.Areas.IdentityServer.Controllers.Profile
var properties =
signInManager.ConfigureExternalAuthenticationProperties(provider,
Url.Action(nameof(AddLoginCallback)), userManager.GetUserId(User));
Url.Action(nameof(AddLoginCallback)), userService.GetUserId(User));
return Challenge(properties, provider);
}
@ -82,14 +80,14 @@ namespace Squidex.Areas.IdentityServer.Controllers.Profile
public Task<IActionResult> AddLoginCallback()
{
return MakeChangeAsync<None>(u => AddLoginAsync(u),
T.Get("users.profile.addLoginDone"));
T.Get("users.profile.addLoginDone"), None.Value);
}
[HttpPost]
[Route("/account/profile/update/")]
public Task<IActionResult> UpdateProfile(ChangeProfileModel model)
{
return MakeChangeAsync(u => UpdateAsync(u, model.ToValues()),
return MakeChangeAsync(id => userService.UpdateAsync(id, model.ToValues()),
T.Get("users.profile.updateProfileDone"), model);
}
@ -97,7 +95,7 @@ namespace Squidex.Areas.IdentityServer.Controllers.Profile
[Route("/account/profile/properties/")]
public Task<IActionResult> UpdateProperties(ChangePropertiesModel model)
{
return MakeChangeAsync(u => UpdateAsync(u, model.ToValues()),
return MakeChangeAsync(id => userService.UpdateAsync(id, model.ToValues()),
T.Get("users.profile.updatePropertiesDone"), model);
}
@ -105,7 +103,7 @@ namespace Squidex.Areas.IdentityServer.Controllers.Profile
[Route("/account/profile/login-remove/")]
public Task<IActionResult> RemoveLogin(RemoveLoginModel model)
{
return MakeChangeAsync(u => userManager.RemoveLoginAsync(u, model.LoginProvider, model.ProviderKey),
return MakeChangeAsync(id => userService.RemoveLoginAsync(id, model.LoginProvider, model.ProviderKey),
T.Get("users.profile.removeLoginDone"), model);
}
@ -113,7 +111,7 @@ namespace Squidex.Areas.IdentityServer.Controllers.Profile
[Route("/account/profile/password-set/")]
public Task<IActionResult> SetPassword(SetPasswordModel model)
{
return MakeChangeAsync(u => userManager.AddPasswordAsync(u, model.Password),
return MakeChangeAsync(id => userService.SetPasswordAsync(id, model.Password),
T.Get("users.profile.setPasswordDone"), model);
}
@ -121,7 +119,7 @@ namespace Squidex.Areas.IdentityServer.Controllers.Profile
[Route("/account/profile/password-change/")]
public Task<IActionResult> ChangePassword(ChangePasswordModel model)
{
return MakeChangeAsync(u => userManager.ChangePasswordAsync(u, model.OldPassword, model.Password),
return MakeChangeAsync(id => userService.SetPasswordAsync(id, model.Password, model.OldPassword),
T.Get("users.profile.changePasswordDone"), model);
}
@ -129,8 +127,8 @@ namespace Squidex.Areas.IdentityServer.Controllers.Profile
[Route("/account/profile/generate-client-secret/")]
public Task<IActionResult> GenerateClientSecret()
{
return MakeChangeAsync<None>(user => userManager.GenerateClientSecretAsync(user),
T.Get("users.profile.generateClientDone"));
return MakeChangeAsync(id => GenerateClientSecretAsync(id),
T.Get("users.profile.generateClientDone"), None.Value);
}
[HttpPost]
@ -138,40 +136,28 @@ namespace Squidex.Areas.IdentityServer.Controllers.Profile
public Task<IActionResult> UploadPicture(List<IFormFile> file)
{
return MakeChangeAsync<None>(user => UpdatePictureAsync(file, user),
T.Get("users.profile.uploadPictureDone"));
T.Get("users.profile.uploadPictureDone"), None.Value);
}
private async Task<IdentityResult> AddLoginAsync(IdentityUser user)
private async Task GenerateClientSecretAsync(string id)
{
var externalLogin = await signInManager.GetExternalLoginInfoWithDisplayNameAsync(userManager.GetUserId(User));
var update = new UserValues { ClientSecret = RandomHash.New() };
return await userManager.AddLoginAsync(user, externalLogin);
await userService.UpdateAsync(id, update);
}
private async Task<IdentityResult> UpdateAsync(IdentityUser user, UserValues values)
private async Task AddLoginAsync(string id)
{
var result = await userManager.UpdateSafeAsync(user, values);
var externalLogin = await signInManager.GetExternalLoginInfoWithDisplayNameAsync(id);
if (result.Succeeded)
{
var resolved = await userManager.ResolveUserAsync(user);
if (resolved != null)
{
userEvents.OnUserUpdated(resolved);
}
await userService.AddLoginAsync(id, externalLogin);
}
return result;
}
private async Task<IdentityResult> UpdatePictureAsync(List<IFormFile> file, IdentityUser user)
private async Task UpdatePictureAsync(List<IFormFile> file, string id)
{
if (file.Count != 1)
{
var description = T.Get("validation.onlyOneFile");
return IdentityResult.Failed(new IdentityError { Code = "PictureNotOneFile", Description = description });
throw new ValidationException(T.Get("validation.onlyOneFile"));
}
using (var thumbnailStream = new MemoryStream())
@ -184,24 +170,24 @@ namespace Squidex.Areas.IdentityServer.Controllers.Profile
}
catch
{
var description = T.Get("validation.notAnImage");
return IdentityResult.Failed(new IdentityError { Code = "PictureNotAnImage", Description = description });
throw new ValidationException(T.Get("validation.notAnImage"));
}
await userPictureStore.UploadAsync(user.Id, thumbnailStream);
await userPictureStore.UploadAsync(id, thumbnailStream);
}
return await userManager.UpdateSafeAsync(user, new UserValues { PictureUrl = SquidexClaimTypes.PictureUrlStore });
var update = new UserValues { PictureUrl = SquidexClaimTypes.PictureUrlStore };
await userService.UpdateAsync(id, update);
}
private async Task<IActionResult> MakeChangeAsync<TModel>(Func<IdentityUser, Task<IdentityResult>> action, string successMessage, TModel? model = null) where TModel : class
private async Task<IActionResult> MakeChangeAsync<TModel>(Func<string, Task> action, string successMessage, TModel? model = null) where TModel : class
{
var user = await userManager.GetUserWithClaimsAsync(User);
var user = await userService.GetAsync(User);
if (user == null)
{
throw new DomainException(T.Get("users.userNotFound"));
return NotFound();
}
if (!ModelState.IsValid)
@ -212,18 +198,17 @@ namespace Squidex.Areas.IdentityServer.Controllers.Profile
string errorMessage;
try
{
var result = await action(user.Identity);
await action(user.Id);
if (result.Succeeded)
{
await signInManager.SignInAsync(user.Identity, true);
await signInManager.SignInAsync((IdentityUser)user.Identity, true);
return RedirectToAction(nameof(Profile), new { successMessage });
}
errorMessage = result.Localize();
catch (ValidationException ex)
{
errorMessage = ex.Message;
}
catch
catch (Exception)
{
errorMessage = T.Get("users.errorHappened");
}
@ -231,7 +216,7 @@ namespace Squidex.Areas.IdentityServer.Controllers.Profile
return View(nameof(Profile), await GetProfileVM(user, model, errorMessage));
}
private async Task<ProfileVM> GetProfileVM<TModel>(UserWithClaims? user, TModel? model = null, string? errorMessage = null, string? successMessage = null) where TModel : class
private async Task<ProfileVM> GetProfileVM<TModel>(IUser? user, TModel? model = null, string? errorMessage = null, string? successMessage = null) where TModel : class
{
if (user == null)
{
@ -240,21 +225,21 @@ namespace Squidex.Areas.IdentityServer.Controllers.Profile
var (providers, hasPassword, logins) = await AsyncHelper.WhenAll(
signInManager.GetExternalProvidersAsync(),
userManager.HasPasswordAsync(user.Identity),
userManager.GetLoginsAsync(user.Identity));
userService.HasPasswordAsync(user),
userService.GetLoginsAsync(user));
var result = new ProfileVM
{
Id = user.Id,
ClientSecret = user.ClientSecret()!,
ClientSecret = user.Claims.ClientSecret()!,
Email = user.Email,
ErrorMessage = errorMessage,
ExternalLogins = logins,
ExternalProviders = providers,
DisplayName = user.DisplayName()!,
DisplayName = user.Claims.DisplayName()!,
HasPassword = hasPassword,
HasPasswordAuth = identityOptions.AllowPasswordAuth,
IsHidden = user.IsHidden(),
IsHidden = user.Claims.IsHidden(),
SuccessMessage = successMessage
};
@ -263,7 +248,7 @@ namespace Squidex.Areas.IdentityServer.Controllers.Profile
SimpleMapper.Map(model, result);
}
result.Properties ??= user.GetCustomProperties().Select(UserProperty.FromTuple).ToList();
result.Properties ??= user.Claims.GetCustomProperties().Select(UserProperty.FromTuple).ToList();
return result;
}

2
backend/src/Squidex/Areas/IdentityServer/Startup.cs

@ -22,7 +22,7 @@ namespace Squidex.Areas.IdentityServer
app.Map(Constants.IdentityServerPrefix, identityApp =>
{
if (environment.IsDevelopment())
if (!environment.IsDevelopment())
{
identityApp.UseDeveloperExceptionPage();
}

4
backend/src/Squidex/Areas/OrleansDashboard/Middlewares/OrleansDashboardAuthenticationMiddleware.cs

@ -33,7 +33,9 @@ namespace Squidex.Areas.OrleansDashboard.Middlewares
if (authentication.Succeeded)
{
if (authentication.Principal?.Permissions().Allows(OrleansPermissions) == true)
var permisisons = authentication.Principal?.Claims.Permissions();
if (permisisons?.Allows(OrleansPermissions) == true)
{
await next(context);
}

2
backend/src/Squidex/Config/Authentication/GithubHandler.cs

@ -22,7 +22,7 @@ namespace Squidex.Config.Authentication
if (!string.IsNullOrWhiteSpace(nameClaim))
{
context.Identity.SetDisplayName(nameClaim);
context.Identity.AddClaim(new Claim(SquidexClaimTypes.DisplayName, nameClaim));
}
if (string.IsNullOrWhiteSpace(context.Identity.FindFirst(ClaimTypes.Email)?.Value))

4
backend/src/Squidex/Config/Authentication/GoogleHandler.cs

@ -29,7 +29,7 @@ namespace Squidex.Config.Authentication
if (!string.IsNullOrWhiteSpace(nameClaim))
{
context.Identity.SetDisplayName(nameClaim);
context.Identity.AddClaim(new Claim(SquidexClaimTypes.DisplayName, nameClaim));
}
string? pictureUrl = null;
@ -54,7 +54,7 @@ namespace Squidex.Config.Authentication
if (!string.IsNullOrWhiteSpace(pictureUrl))
{
context.Identity.SetPictureUrl(pictureUrl);
context.Identity.AddClaim(new Claim(SquidexClaimTypes.PictureUrl, pictureUrl));
}
return base.CreatingTicket(context);

5
backend/src/Squidex/Config/Authentication/MicrosoftHandler.cs

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Security.Claims;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication.OAuth;
@ -25,7 +26,7 @@ namespace Squidex.Config.Authentication
if (!string.IsNullOrEmpty(displayName))
{
context.Identity.SetDisplayName(displayName);
context.Identity.AddClaim(new Claim(SquidexClaimTypes.DisplayName, displayName));
}
string? id = null;
@ -39,7 +40,7 @@ namespace Squidex.Config.Authentication
{
var pictureUrl = $"https://apis.live.net/v5.0/{id}/picture";
context.Identity.SetPictureUrl(pictureUrl);
context.Identity.AddClaim(new Claim(SquidexClaimTypes.PictureUrl, pictureUrl));
}
return base.CreatingTicket(context);

2
backend/src/Squidex/Config/Domain/HistoryServices.cs

@ -21,7 +21,7 @@ namespace Squidex.Config.Domain
config.GetSection("notifo"));
services.AddSingletonAs<NotifoService>()
.AsSelf().As<IUserEventHandler>();
.AsSelf().As<IUserEvents>();
services.AddSingletonAs<HistoryService>()
.As<IEventConsumer>().As<IHistoryService>();

4
backend/src/Squidex/Config/Domain/SubscriptionServices.cs

@ -9,7 +9,6 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Squidex.Domain.Apps.Entities.Apps.Plans;
using Squidex.Domain.Users;
using Squidex.Infrastructure;
using Squidex.Web;
@ -29,9 +28,6 @@ namespace Squidex.Config.Domain
services.AddSingletonAs<UsageGate>()
.AsSelf();
services.AddSingletonAs<UserEvents>()
.AsOptional<IUserEvents>();
}
}
}

207
backend/tests/Squidex.Domain.Users.Tests/DefaultUserResolverTests.cs

@ -6,31 +6,23 @@
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using FakeItEasy;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection;
using Squidex.Infrastructure;
using Squidex.Shared.Users;
using Xunit;
namespace Squidex.Domain.Users
{
public class DefaultUserResolverTests
{
private readonly IUserFactory userFactory = A.Fake<IUserFactory>();
private readonly UserManager<IdentityUser> userManager = A.Fake<UserManager<IdentityUser>>();
private readonly IUserService userService = A.Fake<IUserService>();
private readonly DefaultUserResolver sut;
public DefaultUserResolverTests()
{
A.CallTo(() => userFactory.IsId(A<string>.That.StartsWith("id")))
.Returns(true);
A.CallTo(() => userManager.NormalizeEmail(A<string>._))
.ReturnsLazily(c => c.GetArgument<string>(0)!.ToUpperInvariant());
var serviceProvider = A.Fake<IServiceProvider>();
var scope = A.Fake<IServiceScope>();
@ -46,11 +38,8 @@ namespace Squidex.Domain.Users
A.CallTo(() => serviceProvider.GetService(typeof(IServiceScopeFactory)))
.Returns(scopeFactory);
A.CallTo(() => serviceProvider.GetService(typeof(IUserFactory)))
.Returns(userFactory);
A.CallTo(() => serviceProvider.GetService(typeof(UserManager<IdentityUser>)))
.Returns(userManager);
A.CallTo(() => serviceProvider.GetService(typeof(IUserService)))
.Returns(userService);
sut = new DefaultUserResolver(serviceProvider);
}
@ -58,202 +47,134 @@ namespace Squidex.Domain.Users
[Fact]
public async Task Should_create_user_and_return_true_when_created()
{
var (user, claims) = GenerateUser("id1");
A.CallTo(() => userFactory.Create(user.Email))
.Returns(user);
A.CallTo(() => userManager.CreateAsync(user))
.Returns(IdentityResult.Success);
var email = "123@email.com";
SetupUser(user, claims);
var (result, created) = await sut.CreateUserIfNotExistsAsync(user.Email, false);
Assert.Equal(user.Id, result!.Id);
Assert.Equal(user.Email, result!.Email);
Assert.True(created);
}
[Fact]
public async Task Should_create_user_and_return_false_when_already_exists()
{
var (user, claims) = GenerateUser("id1");
var user = A.Fake<IUser>();
A.CallTo(() => userFactory.Create(user.Email))
A.CallTo(() => userService.CreateAsync(email, A<UserValues>.That.Matches(x => x.Invited == true), false))
.Returns(user);
A.CallTo(() => userManager.CreateAsync(user))
.Returns(IdentityResult.Failed());
SetupUser(user, claims);
var (result, created) = await sut.CreateUserIfNotExistsAsync(user.Email, false);
var result = await sut.CreateUserIfNotExistsAsync(email, true);
Assert.Equal(user.Id, result!.Id);
Assert.Equal(user.Email, result!.Email);
Assert.False(created);
Assert.Equal((user, true), result);
}
[Fact]
public async Task Should_create_user_and_return_false_when_exception_thrown()
{
var (user, claims) = GenerateUser("id1");
A.CallTo(() => userFactory.Create(user.Email))
.Throws(new InvalidOperationException());
var email = "123@email.com";
A.CallTo(() => userManager.CreateAsync(user))
.Returns(IdentityResult.Failed());
var user = A.Fake<IUser>();
SetupUser(user, claims);
A.CallTo(() => userService.CreateAsync(email, A<UserValues>._, false))
.Throws(new InvalidOperationException());
var (result, created) = await sut.CreateUserIfNotExistsAsync(user.Email, false);
A.CallTo(() => userService.FindByEmailAsync(email))
.Returns(user);
Assert.Equal(user.Id, result!.Id);
Assert.Equal(user.Email, result!.Email);
var result = await sut.CreateUserIfNotExistsAsync(email, true);
Assert.False(created);
Assert.Equal((user, false), result);
}
[Fact]
public async Task Should_add_claim_when_not_added_yet()
{
var (user, claims) = GenerateUser("id2");
A.CallTo(() => userManager.AddClaimsAsync(user, A<IEnumerable<Claim>>._))
.Returns(IdentityResult.Success);
var id = "123";
SetupUser(user, claims);
await sut.SetClaimAsync(id, "my-claim", "my-value");
await sut.SetClaimAsync("id2", "my-claim", "new-value");
A.CallTo(() => userManager.AddClaimsAsync(user,
A<IEnumerable<Claim>>.That.Matches(x => x.Any(y => y.Type == "my-claim" && y.Value == "new-value"))))
A.CallTo(() => userService.UpdateAsync(id,
A<UserValues>.That.Matches(x => x.CustomClaims!.Any(y => y.Type == "my-claim" && y.Value == "my-value"))))
.MustHaveHappened();
}
[Fact]
public async Task Should_remove_previous_claim()
public async Task Should_resolve_user_by_email()
{
var (user, claims) = GenerateUser("id2");
claims.Add(new Claim("my-claim", "old-value"));
var id = "123@email.com";
A.CallTo(() => userManager.AddClaimsAsync(user, A<IEnumerable<Claim>>._))
.Returns(IdentityResult.Success);
var user = A.Fake<IUser>();
A.CallTo(() => userManager.RemoveClaimsAsync(user, A<IEnumerable<Claim>>._))
.Returns(IdentityResult.Success);
SetupUser(user, claims);
await sut.SetClaimAsync("id2", "my-claim", "new-value");
A.CallTo(() => userService.FindByEmailAsync(id))
.Returns(user);
A.CallTo(() => userManager.AddClaimsAsync(user,
A<IEnumerable<Claim>>.That.Matches(x => x.Any(y => y.Type == "my-claim" && y.Value == "new-value"))))
.MustHaveHappened();
var result = await sut.FindByIdOrEmailAsync(id);
A.CallTo(() => userManager.RemoveClaimsAsync(user,
A<IEnumerable<Claim>>.That.Matches(x => x.Any(y => y.Type == "my-claim" && y.Value == "old-value"))))
.MustHaveHappened();
Assert.Equal(user, result);
}
[Fact]
public async Task Should_resolve_user_by_email()
public async Task Should_resolve_user_by_id()
{
var (user, claims) = GenerateUser("id1");
var id = "123";
SetupUser(user, claims);
var user = A.Fake<IUser>();
var result = await sut.FindByIdOrEmailAsync(user.Email);
A.CallTo(() => userService.FindByIdAsync(id))
.Returns(user);
Assert.Equal(user.Id, result!.Id);
Assert.Equal(user.Email, result!.Email);
var result = await sut.FindByIdOrEmailAsync(id);
Assert.Equal(claims, result!.Claims);
Assert.Equal(user, result);
}
[Fact]
public async Task Should_resolve_user_by_id()
public async Task Should_resolve_user_by_id_only()
{
var (user, claims) = GenerateUser("id2");
var id = "123";
SetupUser(user, claims);
var user = A.Fake<IUser>();
var result = await sut.FindByIdOrEmailAsync(user.Id)!;
A.CallTo(() => userService.FindByIdAsync(id))
.Returns(user);
Assert.Equal(user.Id, result!.Id);
Assert.Equal(user.Email, result!.Email);
var result = await sut.FindByIdOrEmailAsync(id);
Assert.Equal(claims, result!.Claims);
Assert.Equal(user, result);
}
[Fact]
public async Task Should_resolve_user_by_id_only()
public async Task Should_query_many_by_email()
{
var (user, claims) = GenerateUser("id2");
var email = "hello@squidex.io";
SetupUser(user, claims);
var users = ResultList.CreateFrom(0, A.Fake<IUser>());
var result = await sut.FindByIdAsync(user.Id)!;
A.CallTo(() => userService.QueryAsync(email, 10, 0))
.Returns(users);
Assert.Equal(user.Id, result!.Id);
Assert.Equal(user.Email, result!.Email);
var result = await sut.QueryByEmailAsync(email);
Assert.Equal(claims, result!.Claims);
Assert.Single(result);
}
[Fact]
public async Task Should_query_many_by_email_async()
public async Task Should_query_by_ids()
{
var (user1, claims1) = GenerateUser("id1");
var (user2, claims2) = GenerateUser("id2");
var ids = new[] { "1", "2" };
var list = new List<IdentityUser> { user1, user2 };
var users = ResultList.CreateFrom(0, A.Fake<IUser>());
A.CallTo(() => userManager.Users)
.Returns(list.AsQueryable());
A.CallTo(() => userService.QueryAsync(ids))
.Returns(users);
A.CallTo(() => userManager.GetClaimsAsync(user2))
.Returns(claims2);
var result = await sut.QueryManyAsync(ids);
var result = await sut.QueryByEmailAsync("2");
Assert.Equal(user2.Id, result[0].Id);
Assert.Equal(user2.Email, result[0].Email);
Assert.Equal(claims2, result[0].Claims);
A.CallTo(() => userManager.GetClaimsAsync(user1))
.MustNotHaveHappened();
Assert.Single(result);
}
private static (IdentityUser, List<Claim>) GenerateUser(string id)
[Fact]
public async Task Should_query_all()
{
var user = new IdentityUser { Id = id, Email = $"email_{id}", NormalizedEmail = $"EMAIL_{id}" };
var users = ResultList.CreateFrom(0, A.Fake<IUser>());
var claims = new List<Claim>
{
new Claim($"{id}_a", "1"),
new Claim($"{id}_b", "2")
};
A.CallTo(() => userService.QueryAsync(null, int.MaxValue, 0))
.Returns(users);
return (user, claims);
}
private void SetupUser(IdentityUser user, List<Claim> claims)
{
A.CallTo(() => userManager.FindByEmailAsync(user.Email))
.Returns(user);
A.CallTo(() => userManager.FindByIdAsync(user.Id))
.Returns(user);
var result = await sut.QueryAllAsync();
A.CallTo(() => userManager.GetClaimsAsync(user))
.Returns(claims);
Assert.Single(result);
}
}
}

607
backend/tests/Squidex.Domain.Users.Tests/DefaultUserServiceTests.cs

@ -0,0 +1,607 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using FakeItEasy;
using Microsoft.AspNetCore.Identity;
using Squidex.Infrastructure;
using Squidex.Log;
using Squidex.Shared;
using Squidex.Shared.Identity;
using Squidex.Shared.Users;
using Xunit;
namespace Squidex.Domain.Users
{
public class DefaultUserServiceTests
{
private readonly UserManager<IdentityUser> userManager = A.Fake<UserManager<IdentityUser>>();
private readonly IUserFactory userFactory = A.Fake<IUserFactory>();
private readonly IUserEvents userEvents = A.Fake<IUserEvents>();
private readonly DefaultUserService sut;
public DefaultUserServiceTests()
{
A.CallTo(() => userFactory.IsId(A<string>._))
.Returns(true);
A.CallTo(userManager).WithReturnType<Task<IdentityResult>>()
.Returns(IdentityResult.Success);
sut = new DefaultUserService(userManager, userFactory, Enumerable.Repeat(userEvents, 1), A.Fake<ISemanticLog>());
}
[Fact]
public async Task Should_not_resolve_identity_if_id_not_valid()
{
var invalidId = "__";
A.CallTo(() => userFactory.IsId(invalidId))
.Returns(false);
var result = await sut.FindByIdAsync(invalidId);
Assert.Null(result);
A.CallTo(() => userManager.FindByIdAsync(invalidId))
.MustNotHaveHappened();
}
[Fact]
public async Task Should_return_identity_by_id_if_found()
{
var identity = CreateIdentity(found: true);
var result = await sut.FindByIdAsync(identity.Id);
Assert.Same(identity, result?.Identity);
}
[Fact]
public async Task Should_return_null_if_identity_by_id_not_found()
{
var identity = CreateIdentity(found: false);
var result = await sut.FindByIdAsync(identity.Id);
Assert.Null(result);
}
[Fact]
public async Task Should_return_identity_by_email_if_found()
{
var identity = CreateIdentity(found: true);
var result = await sut.FindByEmailAsync(identity.Email);
Assert.Same(identity, result?.Identity);
}
[Fact]
public async Task Should_return_null_if_identity_by_email_not_found()
{
var identity = CreateIdentity(found: false);
var result = await sut.FindByEmailAsync(identity.Email);
Assert.Null(result);
}
[Fact]
public async Task Should_return_identity_by_login_if_found()
{
var provider = "my-provider";
var providerKey = "key";
var identity = CreateIdentity(found: true);
A.CallTo(() => userManager.FindByLoginAsync(provider, providerKey))
.Returns(identity);
var result = await sut.FindByLoginAsync(provider, providerKey);
Assert.Same(identity, result?.Identity);
}
[Fact]
public async Task Should_return_null_if_identity_by_login_not_found()
{
var provider = "my-provider";
var providerKey = "key";
var identity = CreateIdentity(found: false);
A.CallTo(() => userManager.FindByLoginAsync(provider, providerKey))
.Returns(Task.FromResult<IdentityUser>(null!));
var result = await sut.FindByLoginAsync(provider, providerKey);
Assert.Null(result);
}
[Fact]
public async Task Should_provide_password_existence()
{
var identity = CreateIdentity(found: true);
var user = A.Fake<IUser>();
A.CallTo(() => user.Identity)
.Returns(identity);
A.CallTo(() => userManager.HasPasswordAsync(identity))
.Returns(true);
var result = await sut.HasPasswordAsync(user);
Assert.True(result);
}
[Fact]
public async Task Should_provide_logins()
{
var logins = new List<UserLoginInfo>();
var identity = CreateIdentity(found: true);
var user = A.Fake<IUser>();
A.CallTo(() => user.Identity)
.Returns(identity);
A.CallTo(() => userManager.GetLoginsAsync(identity))
.Returns(logins);
var result = await sut.GetLoginsAsync(user);
Assert.Same(logins, result);
}
[Fact]
public async Task Create_should_add_user()
{
var identity = CreateIdentity(found: false);
var values = new UserValues
{
Email = identity.Email
};
SetupCreation(identity, values, 1);
await sut.CreateAsync(values.Email, values);
A.CallTo(() => userEvents.OnUserRegistered(A<IUser>.That.Matches(x => x.Identity == identity)))
.MustHaveHappened();
A.CallTo(() => userEvents.OnConsentGiven(A<IUser>.That.Matches(x => x.Identity == identity)))
.MustNotHaveHappened();
A.CallTo(() => userManager.AddClaimsAsync(identity, HasClaim(SquidexClaimTypes.Permissions)))
.MustNotHaveHappened();
A.CallTo(() => userManager.AddPasswordAsync(identity, A<string>._))
.MustNotHaveHappened();
A.CallTo(() => userManager.SetLockoutEndDateAsync(identity, A<DateTimeOffset>._))
.MustNotHaveHappened();
}
[Fact]
public async Task Create_should_raise_event_if_consent_given()
{
var identity = CreateIdentity(found: false);
var values = new UserValues
{
Consent = true,
};
SetupCreation(identity, values, 1);
await sut.CreateAsync(identity.Email, values);
A.CallTo(() => userEvents.OnConsentGiven(A<IUser>.That.Matches(x => x.Identity == identity)))
.MustHaveHappened();
}
[Fact]
public async Task Create_should_set_admin_if_first_user()
{
var identity = CreateIdentity(found: false);
var values = new UserValues
{
Consent = true,
};
SetupCreation(identity, values, 0);
await sut.CreateAsync(identity.Email, values);
A.CallTo(() => userManager.AddClaimsAsync(identity, HasClaim(SquidexClaimTypes.Permissions, Permissions.Admin)))
.MustHaveHappened();
}
[Fact]
public async Task Create_should_not_lock_first_user()
{
var identity = CreateIdentity(found: false);
var values = new UserValues
{
Consent = true,
};
SetupCreation(identity, values, 0);
await sut.CreateAsync(identity.Email, values, true);
A.CallTo(() => userManager.SetLockoutEndDateAsync(identity, A<DateTimeOffset>._))
.MustNotHaveHappened();
}
[Fact]
public async Task Create_should_lock_second_user()
{
var identity = CreateIdentity(found: false);
var values = new UserValues
{
Consent = true,
};
SetupCreation(identity, values, 1);
await sut.CreateAsync(identity.Email, values, true);
A.CallTo(() => userManager.SetLockoutEndDateAsync(identity, InFuture()))
.MustHaveHappened();
}
[Fact]
public async Task Create_should_add_password()
{
var identity = CreateIdentity(found: false);
var values = new UserValues
{
Password = "password"
};
SetupCreation(identity, values, 1);
await sut.CreateAsync(identity.Email, values, false);
A.CallTo(() => userManager.AddPasswordAsync(identity, values.Password))
.MustHaveHappened();
}
[Fact]
public async Task Update_should_throw_exception_if_not_found()
{
var update = new UserValues
{
Email = "new@email.com"
};
var identity = CreateIdentity(found: false);
await Assert.ThrowsAsync<DomainObjectNotFoundException>(() => sut.UpdateAsync(identity.Id, update));
}
[Fact]
public async Task Update_should_do_nothing_for_new_update()
{
var update = new UserValues();
var identity = CreateIdentity(found: true);
await sut.UpdateAsync(identity.Id, update);
A.CallTo(() => userEvents.OnUserUpdated(A<IUser>.That.Matches(x => x.Identity == identity)))
.MustHaveHappened();
}
[Fact]
public async Task Update_should_change_password_if_changed()
{
var update = new UserValues
{
Password = "password"
};
var identity = CreateIdentity(found: true);
A.CallTo(() => userManager.HasPasswordAsync(identity))
.Returns(true);
await sut.UpdateAsync(identity.Id, update);
A.CallTo(() => userManager.RemovePasswordAsync(identity))
.MustHaveHappened();
A.CallTo(() => userManager.AddPasswordAsync(identity, update.Password))
.MustHaveHappened();
}
[Fact]
public async Task Update_should_change_email_if_changed()
{
var update = new UserValues
{
Email = "new@email.com"
};
var identity = CreateIdentity(found: true);
await sut.UpdateAsync(identity.Id, update);
A.CallTo(() => userManager.SetEmailAsync(identity, update.Email))
.MustHaveHappened();
A.CallTo(() => userManager.SetUserNameAsync(identity, update.Email))
.MustHaveHappened();
}
[Fact]
public async Task Update_should_set_claim_if_consent_given()
{
var update = new UserValues
{
Consent = true
};
var identity = CreateIdentity(found: true);
await sut.UpdateAsync(identity.Id, update);
A.CallTo<Task<IdentityResult>>(() => userManager.AddClaimsAsync(identity, HasClaim(SquidexClaimTypes.Consent)))
.MustHaveHappened();
A.CallTo(() => userEvents.OnConsentGiven(A<IUser>.That.Matches(x => x.Identity == identity)))
.MustHaveHappened();
}
[Fact]
public async Task Update_should_set_claim_if_email_consent_given()
{
var update = new UserValues
{
ConsentForEmails = true
};
var identity = CreateIdentity(found: true);
await sut.UpdateAsync(identity.Id, update);
A.CallTo<Task<IdentityResult>>(() => userManager.AddClaimsAsync(identity, HasClaim(SquidexClaimTypes.ConsentForEmails)))
.MustHaveHappened();
A.CallTo(() => userEvents.OnConsentGiven(A<IUser>.That.Matches(x => x.Identity == identity)))
.MustHaveHappened();
}
[Fact]
public async Task SetPassword_should_throw_exception_if_not_found()
{
var password = "password";
var identity = CreateIdentity(found: false);
await Assert.ThrowsAsync<DomainObjectNotFoundException>(() => sut.SetPasswordAsync(identity.Id, password, null));
}
[Fact]
public async Task SetPassword_should_succeed_if_found()
{
var password = "password";
var identity = CreateIdentity(found: true);;
await sut.SetPasswordAsync(identity.Id, password, null);
A.CallTo(() => userManager.AddPasswordAsync(identity, password))
.MustHaveHappened();
}
[Fact]
public async Task SetPassword_should_change_password_if_identity_has_password()
{
var password = "password";
var identity = CreateIdentity(found: true);
A.CallTo(() => userManager.HasPasswordAsync(identity))
.Returns(true);
await sut.SetPasswordAsync(identity.Id, password, "old");
A.CallTo(() => userManager.ChangePasswordAsync(identity, "old", password))
.MustHaveHappened();
}
[Fact]
public async Task AddLogin_should_throw_exception_if_not_found()
{
var login = A.Fake<ExternalLoginInfo>();
var identity = CreateIdentity(found: false);
await Assert.ThrowsAsync<DomainObjectNotFoundException>(() => sut.AddLoginAsync(identity.Id, login));
}
[Fact]
public async Task AddLogin_should_succeed_if_found()
{
var login = A.Fake<ExternalLoginInfo>();
var identity = CreateIdentity(found: true);
await sut.AddLoginAsync(identity.Id, login);
A.CallTo(() => userManager.AddLoginAsync(identity, login))
.MustHaveHappened();
}
[Fact]
public async Task RemoveLogin_should_throw_exception_if_not_found()
{
var provider = "my-provider";
var providerKey = "key";
var identity = CreateIdentity(found: false);
await Assert.ThrowsAsync<DomainObjectNotFoundException>(() => sut.RemoveLoginAsync(identity.Id, provider, providerKey));
}
[Fact]
public async Task RemoveLogin_should_succeed_if_found()
{
var provider = "my-provider";
var providerKey = "key";
var identity = CreateIdentity(found: true);
await sut.RemoveLoginAsync(identity.Id, provider, providerKey);
A.CallTo(() => userManager.RemoveLoginAsync(identity, provider, providerKey))
.MustHaveHappened();
}
[Fact]
public async Task Lock_should_throw_exception_if_not_found()
{
var identity = CreateIdentity(found: false);
await Assert.ThrowsAsync<DomainObjectNotFoundException>(() => sut.LockAsync(identity.Id));
}
[Fact]
public async Task Lock_should_succeed_if_found()
{
var identity = CreateIdentity(found: true);
await sut.LockAsync(identity.Id);
A.CallTo<Task<IdentityResult>>(() => userManager.SetLockoutEndDateAsync(identity, InFuture()))
.MustHaveHappened();
}
[Fact]
public async Task Unlock_should_throw_exception_if_not_found()
{
var identity = CreateIdentity(found: false);
await Assert.ThrowsAsync<DomainObjectNotFoundException>(() => sut.UnlockAsync(identity.Id));
}
[Fact]
public async Task Unlock_should_succeeed_if_found()
{
var identity = CreateIdentity(found: true);
await sut.UnlockAsync(identity.Id);
A.CallTo(() => userManager.SetLockoutEndDateAsync(identity, null))
.MustHaveHappened();
}
[Fact]
public async Task Delete_should_throw_exception_if_not_found()
{
var identity = CreateIdentity(found: false);
await Assert.ThrowsAsync<DomainObjectNotFoundException>(() => sut.DeleteAsync(identity.Id));
A.CallTo(() => userEvents.OnUserDeleted(A<IUser>._))
.MustNotHaveHappened();
}
[Fact]
public async Task Delete_should_succeed_if_found()
{
var identity = CreateIdentity(found: true);
await sut.DeleteAsync(identity.Id);
A.CallTo(() => userManager.DeleteAsync(identity))
.MustHaveHappened();
A.CallTo(() => userEvents.OnUserDeleted(A<IUser>.That.Matches(x => x.Identity == identity)))
.MustHaveHappened();
}
private IdentityUser CreateIdentity(bool found, string id = "123")
{
var identity = CreatePendingUser(id);
if (found)
{
A.CallTo(() => userManager.FindByIdAsync(identity.Id))
.Returns(identity);
A.CallTo(() => userManager.FindByEmailAsync(identity.Email))
.Returns(identity);
}
else
{
A.CallTo(() => userManager.FindByIdAsync(identity.Id))
.Returns(Task.FromResult<IdentityUser>(null!));
A.CallTo(() => userManager.FindByEmailAsync(identity.Email))
.Returns(Task.FromResult<IdentityUser>(null!));
}
return identity;
}
private void SetupCreation(IdentityUser identity, UserValues values, int numCurrentUsers)
{
var users = new List<IdentityUser>();
for (var i = 0; i < numCurrentUsers; i++)
{
users.Add(CreatePendingUser(i.ToString()));
}
A.CallTo(() => userManager.Users)
.Returns(users.AsQueryable());
A.CallTo(() => userFactory.Create(identity.Email))
.Returns(identity);
}
private static IEnumerable<Claim> HasClaim(string claim)
{
return A<IEnumerable<Claim>>.That.Matches(x => x.Any(y => y.Type == claim));
}
private static IEnumerable<Claim> HasClaim(string claim, string value)
{
return A<IEnumerable<Claim>>.That.Matches(x => x.Any(y => y.Type == claim && y.Value == value));
}
private static DateTimeOffset InFuture()
{
return A<DateTimeOffset>.That.Matches(x => x >= DateTimeOffset.UtcNow.AddYears(1));
}
private static IdentityUser CreatePendingUser(string id = "123")
{
return new IdentityUser
{
Id = id,
Email = $"{id}@email.com"
};
}
}
}

4
backend/tools/TestSuite/TestSuite.ApiTests/AppTests.cs

@ -162,7 +162,7 @@ namespace TestSuite.ApiTests
var contributor_1 = contributors_1.Items.FirstOrDefault(x => x.ContributorName == contributorEmail);
// Should return contributor with correct email.
Assert.Equal(contributorRole1, contributor_1.Role);
Assert.Equal(contributorRole1, contributor_1?.Role);
// STEP 2: Update contributor role.
@ -172,7 +172,7 @@ namespace TestSuite.ApiTests
var contributor_2 = contributors_2.Items.FirstOrDefault(x => x.ContributorId == contributor_1.ContributorId);
// Should return contributor with correct role.
Assert.Equal(contributorRole2, contributor_2.Role);
Assert.Equal(contributorRole2, contributor_2?.Role);
// STEP 3: Remove contributor.

11
frontend/app/features/administration/pages/users/user.component.html

@ -8,13 +8,22 @@
<td class="cell-auto">
<span class="user-email table-cell">{{user.email}}</span>
</td>
<td class="cell-actions">
<td class="cell-actions-lg">
<button type="button" class="btn btn-text" (click)="lock()" sqxStopClick *ngIf="user.canLock" title="i18n:users.lockTooltip">
<i class="icon icon-unlocked"></i>
</button>
<button type="button" class="btn btn-text" (click)="unlock()" sqxStopClick *ngIf="user.canUnlock" title="i18n:users.unlockTooltip">
<i class="icon icon-lock"></i>
</button>
<button type="button" class="btn btn-text-danger" [disabled]="!user.canDelete"
(sqxConfirmClick)="delete()"
confirmTitle="i18n:users.deleteConfirmTitle"
confirmText="i18n:users.deleteConfirmText"
confirmRememberKey="deleteUser"
sqxStopClick>
<i class="icon-bin2"></i>
</button>
</td>
</tr>
<tr class="spacer"></tr>

4
frontend/app/features/administration/pages/users/user.component.ts

@ -32,4 +32,8 @@ export class UserComponent {
public unlock() {
this.usersState.unlock(this.user);
}
public delete() {
this.usersState.delete(this.user);
}
}

2
frontend/app/features/administration/pages/users/users-page.component.html

@ -41,7 +41,7 @@
<th class="cell-auto">
{{ 'common.email' | sqxTranslate }}
</th>
<th class="cell-actions">
<th class="cell-actions-lg">
{{ 'common.actions' | sqxTranslate }}
</th>
</tr>

19
frontend/app/features/administration/services/users.service.spec.ts

@ -202,6 +202,25 @@ describe('UsersService', () => {
expect(user!).toEqual(createUser(12));
}));
it('should make delete request to delete user',
inject([UsersService, HttpTestingController], (userManagementService: UsersService, httpMock: HttpTestingController) => {
const resource: Resource = {
_links: {
delete: { method: 'DELETE', href: 'api/user-management/123' }
}
};
userManagementService.deleteUser(resource).subscribe();
const req = httpMock.expectOne('http://service/p/api/user-management/123');
expect(req.request.method).toEqual('DELETE');
expect(req.request.headers.get('If-Match')).toBeNull();
req.flush({});
}));
function userResponse(id: number) {
return {
id: `${id}`,

15
frontend/app/features/administration/services/users.service.ts

@ -23,6 +23,7 @@ export class UserDto {
public readonly canLock: boolean;
public readonly canUnlock: boolean;
public readonly canUpdate: boolean;
public readonly canDelete: boolean;
constructor(links: ResourceLinks,
public readonly id: string,
@ -36,6 +37,7 @@ export class UserDto {
this.canLock = hasAnyLink(links, 'lock');
this.canUnlock = hasAnyLink(links, 'unlock');
this.canUpdate = hasAnyLink(links, 'update');
this.canDelete = hasAnyLink(links, 'delete');
}
}
@ -114,7 +116,7 @@ export class UsersService {
map(body => {
return parseUser(body);
}),
pretifyError('i18n:users.loadFailed'));
pretifyError('i18n:users.lockFailed'));
}
public unlockUser(user: Resource): Observable<UserDto> {
@ -126,7 +128,16 @@ export class UsersService {
map(body => {
return parseUser(body);
}),
pretifyError('i18n:users.loadFailed'));
pretifyError('i18n:users.unlockFailed'));
}
public deleteUser(user: Resource): Observable<any> {
const link = user._links['delete'];
const url = this.apiUrl.buildUrl(link.href);
return this.http.request(link.method, url).pipe(
pretifyError('i18n:users.deleteFailed'));
}
}

10
frontend/app/features/administration/state/users.state.spec.ts

@ -225,6 +225,16 @@ describe('UsersState', () => {
expect(usersState.snapshot.total).toBe(201);
});
it('should remove user from snapshot when delete', () => {
usersService.setup(x => x.deleteUser(user1))
.returns(() => of(newUser)).verifiable();
usersState.delete(user1).subscribe();
expect(usersState.snapshot.users).toEqual([user2]);
expect(usersState.snapshot.total).toBe(199);
});
it('should truncate users when page size reached', () => {
const request = { ...newUser, password: 'password' };

12
frontend/app/features/administration/state/users.state.ts

@ -164,6 +164,18 @@ export class UsersState extends State<Snapshot> {
shareSubscribed(this.dialogs));
}
public delete(user: UserDto) {
return this.usersService.deleteUser(user).pipe(
tap(updated => {
this.next(s => {
const users = s.users.filter(x => x.id !== user.id);
return { ...s, users, total: s.total - 1 };
}, 'Delete');
}),
shareSubscribed(this.dialogs));
}
public search(query: string) {
if (!this.next({ query, page: 0 }, 'Loading Search')) {
return EMPTY;

6
frontend/app/features/content/pages/content/content-page.component.html

@ -21,17 +21,17 @@
<ul class="nav nav-tabs2" *ngIf="contentTab | async; let tab">
<li class="nav-item">
<a class="nav-link" [routerLink]="[]" [queryParams]="{ tab: 'editor' }" [class.active]="tab === 'editor'">
{{ 'i18n:contents.contentTab.editor' | sqxTranslate }}
{{ 'contents.contentTab.editor' | sqxTranslate }}
</a>
</li>
<li>
<a class="nav-link" [routerLink]="[]" [queryParams]="{ tab: 'references' }" [class.active]="tab === 'references'">
{{ 'i18n:contents.contentTab.references' | sqxTranslate }}
{{ 'contents.contentTab.references' | sqxTranslate }}
</a>
</li>
<li>
<a class="nav-link" [routerLink]="[]" [queryParams]="{ tab: 'referencing' }" [class.active]="tab === 'referencing'">
{{ 'i18n:contents.contentTab.referencing' | sqxTranslate }}
{{ 'contents.contentTab.referencing' | sqxTranslate }}
</a>
</li>
</ul>

4
frontend/app/features/content/pages/content/references/content-references.component.html

@ -15,10 +15,10 @@
<tbody *ngIf="(contentsState.isLoaded | async) && contents.length === 0">
<tr>
<td class="table-items-row-empty" *ngIf="mode === 'references'">
{{ 'i18n:contents.noReferences' | sqxTranslate }}
{{ 'contents.noReferences' | sqxTranslate }}
</td>
<td class="table-items-row-empty" *ngIf="mode === 'referencing'">
{{ 'i18n:contents.noReferencing' | sqxTranslate }}
{{ 'contents.noReferencing' | sqxTranslate }}
</td>
</tr>
</tbody>

10
frontend/app/features/schemas/pages/schema/schema-page.component.html

@ -5,27 +5,27 @@
<ul class="nav nav-tabs2">
<li class="nav-item">
<a class="nav-link" [routerLink]="[]" [queryParams]="{ tab: 'fields' }" [class.active]="tab === 'fields'">
{{ 'i18n:schemas.tabFields' | sqxTranslate }}
{{ 'schemas.tabFields' | sqxTranslate }}
</a>
</li>
<li>
<a class="nav-link" [routerLink]="[]" [queryParams]="{ tab: 'ui' }" [class.active]="tab === 'ui'">
{{ 'i18n:schemas.tabUI' | sqxTranslate }}
{{ 'schemas.tabUI' | sqxTranslate }}
</a>
</li>
<li>
<a class="nav-link" [routerLink]="[]" [queryParams]="{ tab: 'scripts' }" [class.active]="tab === 'scripts'">
{{ 'i18n:schemas.tabScripts' | sqxTranslate }}
{{ 'schemas.tabScripts' | sqxTranslate }}
</a>
</li>
<li>
<a class="nav-link" [routerLink]="[]" [queryParams]="{ tab: 'json' }" [class.active]="tab === 'json'">
{{ 'i18n:schemas.tabJson' | sqxTranslate }}
{{ 'schemas.tabJson' | sqxTranslate }}
</a>
</li>
<li>
<a class="nav-link" [routerLink]="[]" [queryParams]="{ tab: 'more' }" [class.active]="tab === 'more'">
{{ 'i18n:schemas.tabMore' | sqxTranslate }}
{{ 'schemas.tabMore' | sqxTranslate }}
</a>
</li>
</ul>

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

Loading…
Cancel
Save