From 83fc61c16b621987c35ace7970ba63209d9d3e1b Mon Sep 17 00:00:00 2001
From: claire bontempo <68122737+hellobontempo@users.noreply.github.com>
Date: Thu, 8 Sep 2022 18:06:05 -0700
Subject: [PATCH] UI: OIDC Config for Vault as a provider (#17071)
* OIDC Config Routing (#16028)
* adds oidc config routes
* renames oidc applications route to clients
* UI/vault 6646/landing page (#16069)
* add to sidebar
* add landing image and text
* add permissions
* add permissions to permissions service
* remove comment
* fix.
* UI/OIDC models (#16091)
* add models and fix routing
* add ClientsCreate route
* remove form functions from client model
* update comment
* address comments, cleanup models
* add comment
* OIDC Adapters and Serializers (#16120)
* adds named-path base adapter
* adds oidc adapters with tests
* adds oidc serializers
* fixes issue with supported_scopes relationship in oidc provider model
* make radio card size flex (#16125)
* OIDC config details routes (#16126)
* adds details routes for oidc config resources
* adds details templates for oidc config resources
* OIDC parent route and index redirection (#16139)
* adds parent oidc route with header and adds redirection if clients have been created
* updates learn link
* adds findRecord override to named-path adapter (#16145)
* OIDC Scope Create/Edit View (#16174)
* adds oidc scope-form to create and edit views
* moves oidc header set logic from route to controller
* OIDC Scope Details View (#16191)
* adds oidc scope details view
* removes disabled arg from scope delete confirm action
* updates oidc scope template params link to use DocLink and adds success message on scope create success
* updates oidc scope delete confirm action copy
* adds oidc scopes list (#16196)
* UI/vault 6655/OIDC create view (#16331)
* setup header
* wip
* wip
* wip
* validations
* error validations
* cleanup
* wip
* fix error
* clean up
* handle modelValidations
* add documentation on the decorator
* remove spread attrs
* first test and some fixes
* halfway with test
* fix error where the data object was sending param entiyIds and not entity_ids
* validations or situation
* fix test
* small nit:
* test if this fixes the test
* fix
* cleanup
* nit
* Assignments Update/Edit View (#16412)
* wip
* fix
* render search-select after promise is fulfilled
* add test coverage
Co-authored-by: clairebontempo@gmail.com
* Added list view for keys (#16454)
* Added list view for providers (#16442)
* Added list view for providers
* Removed check for model data length
* Added new line at end of file
* Fixed linting issues causing ui tests to fail
* Added list view for application (#16469)
* UI/remove has many relationship (#16470)
* remove hasMany from models
* remove relationships from assignments create form
* update tests
* Assignment list view (#16340)
* inital setup
* handle default allow all
* add learn more link
* Fixed the default allow_all for assignment list view to match Figma design
* Fixed linting
* Fixed hbs file syntax
Co-authored-by: linda9379
* configure mirage and helper (#16482)
* UI/OIDC client form (#16131)
* WIP client form
* wip
* still WIP
* fix form!;
* remove computeds, cache form attrs instead
* update scope form component name
* add white space validation
* add validations, cleanup
* add edit form
* fix link to in edit form
* disable edit form
* fix linkto
* wip/ search select filter
* WIP/search-select bug
* fix assignment save
* delete old modal js file
* glimmerize/create new search select modal component
* component cleanup
* fix bugginess
* fix search select and radio select action
* add tests
* revert some test changes
* oops, removed test tag
* add key list to response
* fix test
* move search select component to separate PR, revert changes
* one more revert
* remove oidc helper from this pr
* remove hasMany relationship
* minor cleanup
* update assignment form to use fallback
* fix allow_all appearing in dropdown on edit (#16508)
* UI/ OIDC Application (client) details view (#16507)
* fix test
* finish details page
* finish details view
* clean u[
* fix typo
* configure oidc mirage handler for tests
* remove params, add new route instead
* fix headers
* remove console.log
* remove controller/template reliance on tracked variable
* rename variable
* UI/Client route acceptance tests - fixed branch (#16654)
* WIP client route tests
* refactor client form so clientType is not edit-able
* fix ttl in client form
* wip// more acceptance tests and tags for hbs files
* fix typo
* fix syntax error
* finish tests
* fix client form test
* resolve commits
* update form test
* OIDC Assignments Details view. (#16511)
* setup
* cleanup
* view all fix
* wip setting up tabs
* wip
* revert to no queryParam or tabs
* add the read more component and styling
* rename folder
* cleanup
* fix
* UI/OIDC providers create/edit route (#16612)
* update to use DocLink component
* provider create form
* cleaup
* add formt est
* revert label text
* update doclink test
* disallow new scopes from ss
* fix test typo
* fix provider form flash message
* add period
* test new form field attr
* refactor form input
* fix edit portion of issuer field
* add test selector to new input field
* add comment
* Cleanup OIDC Config Mirage handler (#16674)
* cleaup mirage
* change to .then
* pull out into config file
* Scope acceptance tests (#16707)
* Started writing acceptance tests
* Added some more acceptance tests
* Added tags for hbs and more tests
* Modified variable names in scope form test
* Fixed tests and linting
* UI/OIDC Provider read view (#16632)
* add providers/provider/client route
* provider details view
* add disabled button and tooltip for default
* add toolbar separators
* revert unrelated change
* query all client records and filter by allowed client id"
* refactor adapter to filter for clientId
* cleanup adapter method
* update test
* refactor test
* fix tests to accommodate for serializer change
* update empty state message
* fix linting
* metadata for client list view (#16725)
* Added metadata for list view in clients
* Fixed linting
* Fixed failing ui test
* fix scopes and clients tests (#16768)
* Initial fix of tests
* Fixed failing scopes and clients acceptance tests
* Fixed linting
* UI: Key create/edit form (#16729)
* add route models
* add forms
* add test
* remove helperText attr
* metadata for provider list view (#16738)
* Added meta-data for provider list view
* Added comment for serializer
* Fixed import path for scopes and clients acceptance test files
* UI/Add client ids to search select (#16744)
* WIP use clientID instead of name
* add client ids to search select
* remove provider form component changes
* fix search select on edit
* cleanup comments and method
* fix adapter query method
* clean up comments
* add test
* remove destructuring so linting passes
* fix tests
* add accidentally deleted param
* add clarifying comments
* cleanup
* change how shouldRenderName is set
* cleanup tests
* address comments
* OIDC Assignment Acceptance tests (#16741)
* test and fixes
* merge stuff
* fix
* fixes
* add waituntil
* inconsistent nav issue
* fixes
* blah
* UI/Key details view (#16776)
* add details view
* reformat model file
* todo for when listing applications
* add comment
* update key form with refactored search select
* add applications list
* update test
* update test
* add names to flash messages
* add rollbackAttributes to delete catch (#16796)
* UI: Checks if records exists before creating record when URL contains :name (#16823)
* check for record existing in createRecord
* use error banner instead of flash messages for forms
* add inline form message for validations
* add error count message to inlinealert
* add test for adapter
* add tests
* remove unused vars
* UI: Disable limiting clients when creating key, filter clients when editing (#16926)
* add tooltip to disabled radio button
* pass query object to search select
* update copy
* add comment
* cleanup console log and comment
* fix tests
* revert change because addressed in other pr
* fix diff
* fix test
* UI: Add redirect when last client is deleted (#16927)
* afterModel redirect if no models exist
* fix test
* change space
* fix incorrect text
* UI: Add InfoTooltip to selected 'ghost' client_ids (#16942)
* return option if undefined
* add info tooltip to search select
* change word
* add test
* UI: OIDC config keys acceptance tests (#16968)
* add keys test
* update other oidc tests
* remove-search select comment
* UI: Filter Client providers list view (#17027)
* pass param to adapter
* add test
* UI: OIDC Config Acceptance Tests (#17050)
* WIP/provider acceptance tests"
* WIP/this commit breaks lots of things
* fix tests
* update test selectors
* combine key and client tests
* cleanup clients and keys test
* finish tests
* small tidying
* UI: Remove trailing comma from scopes, provider details page (#17069)
* use info table row to cleanup scope logic
* infotableitemarray cleanup
* tidying
* add changelog
* teeny little empty state
* fix wildcard string helper not working
Co-authored-by: Jordan Reimer
Co-authored-by: Angel Garbarino
Co-authored-by: Angel Garbarino
Co-authored-by: linda9379 <57650314+linda9379@users.noreply.github.com>
Co-authored-by: linda9379
---
changelog/17071.txt | 2 +
ui/app/adapters/named-path.js | 76 ++++
ui/app/adapters/oidc/assignment.js | 7 +
ui/app/adapters/oidc/client.js | 7 +
ui/app/adapters/oidc/key.js | 11 +
ui/app/adapters/oidc/provider.js | 7 +
ui/app/adapters/oidc/scope.js | 7 +
ui/app/components/oidc/assignment-form.js | 72 ++++
ui/app/components/oidc/client-form.js | 91 ++++
ui/app/components/oidc/key-form.js | 89 ++++
ui/app/components/oidc/provider-form.js | 85 ++++
ui/app/components/oidc/scope-form.js | 62 +++
ui/app/components/regex-validator.hbs | 4 +-
.../controllers/vault/cluster/access/oidc.js | 34 ++
.../oidc/assignments/assignment/details.js | 21 +
.../cluster/access/oidc/clients/client.js | 21 +
.../access/oidc/clients/client/details.js | 21 +
.../vault/cluster/access/oidc/keys/key.js | 20 +
.../cluster/access/oidc/keys/key/details.js | 36 ++
.../cluster/access/oidc/providers/provider.js | 20 +
.../access/oidc/providers/provider/details.js | 21 +
.../access/oidc/scopes/scope/details.js | 21 +
ui/app/decorators/model-validations.js | 13 +-
ui/app/models/database/connection.js | 4 +-
ui/app/models/oidc/assignment.js | 59 +++
ui/app/models/oidc/client.js | 134 ++++++
ui/app/models/oidc/key.js | 64 +++
ui/app/models/oidc/provider.js | 74 ++++
ui/app/models/oidc/scope.js | 42 ++
ui/app/router.js | 40 ++
ui/app/routes/vault/cluster/access/oidc.js | 3 +
.../access/oidc/assignments/assignment.js | 7 +
.../oidc/assignments/assignment/details.js | 3 +
.../oidc/assignments/assignment/edit.js | 3 +
.../cluster/access/oidc/assignments/create.js | 7 +
.../cluster/access/oidc/assignments/index.js | 13 +
.../cluster/access/oidc/clients/client.js | 7 +
.../access/oidc/clients/client/details.js | 2 +
.../access/oidc/clients/client/edit.js | 3 +
.../access/oidc/clients/client/providers.js | 18 +
.../cluster/access/oidc/clients/create.js | 7 +
.../cluster/access/oidc/clients/index.js | 21 +
.../routes/vault/cluster/access/oidc/index.js | 18 +
.../vault/cluster/access/oidc/keys/create.js | 7 +
.../vault/cluster/access/oidc/keys/index.js | 12 +
.../vault/cluster/access/oidc/keys/key.js | 7 +
.../cluster/access/oidc/keys/key/clients.js | 8 +
.../cluster/access/oidc/keys/key/details.js | 3 +
.../cluster/access/oidc/keys/key/edit.js | 3 +
.../cluster/access/oidc/providers/create.js | 7 +
.../cluster/access/oidc/providers/index.js | 13 +
.../cluster/access/oidc/providers/provider.js | 7 +
.../access/oidc/providers/provider/clients.js | 8 +
.../access/oidc/providers/provider/details.js | 3 +
.../access/oidc/providers/provider/edit.js | 3 +
.../cluster/access/oidc/scopes/create.js | 7 +
.../vault/cluster/access/oidc/scopes/index.js | 13 +
.../vault/cluster/access/oidc/scopes/scope.js | 7 +
.../access/oidc/scopes/scope/details.js | 3 +
.../cluster/access/oidc/scopes/scope/edit.js | 3 +
ui/app/serializers/oidc/assignment.js | 5 +
ui/app/serializers/oidc/client.js | 17 +
ui/app/serializers/oidc/key.js | 5 +
ui/app/serializers/oidc/provider.js | 17 +
ui/app/serializers/oidc/scope.js | 5 +
ui/app/services/permissions.js | 2 +
ui/app/styles/components/list-item-row.scss | 4 +
ui/app/styles/components/radio-card.scss | 5 +-
ui/app/styles/core/buttons.scss | 10 +
ui/app/styles/core/forms.scss | 5 +
ui/app/styles/core/helpers.scss | 3 +
ui/app/templates/components/alphabet-edit.hbs | 1 -
.../templates/components/clients/config.hbs | 8 +-
.../mfa/mfa-login-enforcement-form.hbs | 2 +-
.../components/oidc/assignment-form.hbs | 95 +++++
.../templates/components/oidc/client-form.hbs | 99 +++++
.../templates/components/oidc/client-list.hbs | 52 +++
ui/app/templates/components/oidc/key-form.hbs | 100 +++++
.../components/oidc/provider-form.hbs | 127 ++++++
.../components/oidc/provider-list.hbs | 52 +++
.../templates/components/oidc/scope-form.hbs | 113 +++++
ui/app/templates/components/radio-card.hbs | 67 +--
.../components/transform-role-edit.hbs | 1 -
.../transform-show-transformation.hbs | 1 -
ui/app/templates/vault/cluster/access.hbs | 7 +
.../templates/vault/cluster/access/oidc.hbs | 59 +++
.../oidc/assignments/assignment/details.hbs | 85 ++++
.../oidc/assignments/assignment/edit.hbs | 5 +
.../access/oidc/assignments/create.hbs | 5 +
.../cluster/access/oidc/assignments/index.hbs | 71 ++++
.../cluster/access/oidc/clients/client.hbs | 44 ++
.../access/oidc/clients/client/details.hbs | 43 ++
.../access/oidc/clients/client/edit.hbs | 5 +
.../access/oidc/clients/client/providers.hbs | 13 +
.../cluster/access/oidc/clients/create.hbs | 5 +
.../cluster/access/oidc/clients/index.hbs | 9 +
.../vault/cluster/access/oidc/index.hbs | 14 +
.../vault/cluster/access/oidc/keys/create.hbs | 5 +
.../vault/cluster/access/oidc/keys/index.hbs | 56 +++
.../vault/cluster/access/oidc/keys/key.hbs | 36 ++
.../cluster/access/oidc/keys/key/clients.hbs | 14 +
.../cluster/access/oidc/keys/key/details.hbs | 54 +++
.../cluster/access/oidc/keys/key/edit.hbs | 5 +
.../cluster/access/oidc/providers/create.hbs | 5 +
.../cluster/access/oidc/providers/index.hbs | 9 +
.../access/oidc/providers/provider.hbs | 44 ++
.../oidc/providers/provider/clients.hbs | 14 +
.../oidc/providers/provider/details.hbs | 52 +++
.../access/oidc/providers/provider/edit.hbs | 5 +
.../cluster/access/oidc/scopes/create.hbs | 5 +
.../cluster/access/oidc/scopes/index.hbs | 73 ++++
.../access/oidc/scopes/scope/details.hbs | 65 +++
.../cluster/access/oidc/scopes/scope/edit.hbs | 5 +
ui/lib/core/addon/components/alert-inline.js | 12 +-
.../addon/components/form-field-groups.hbs | 49 ++-
.../addon/components/form-field-label.hbs | 4 +-
ui/lib/core/addon/components/form-field.hbs | 78 ++--
.../components/info-table-item-array.hbs | 115 ++---
.../addon/components/info-table-item-array.js | 39 +-
.../core/addon/components/info-table-row.hbs | 22 +-
ui/lib/core/addon/components/radio-button.hbs | 9 +-
ui/lib/core/addon/components/read-more.js | 3 +-
.../components/search-select-with-modal.hbs | 2 +-
ui/lib/core/addon/components/search-select.js | 61 ++-
ui/lib/core/addon/components/tool-tip.js | 2 +-
.../addon/templates/components/box-radio.hbs | 4 +-
.../components/readonly-form-field.hbs | 1 +
.../templates/components/search-select.hbs | 17 +-
ui/mirage/config.js | 2 +
ui/mirage/handlers/index.js | 5 +-
ui/mirage/handlers/oidc-config.js | 17 +
ui/public/images/oidc-landing.png | 3 +
.../oidc-config/clients-assignments-test.js | 349 ++++++++++++++++
.../oidc-config/clients-keys-test.js | 305 ++++++++++++++
.../oidc-config/providers-scopes-test.js | 395 ++++++++++++++++++
ui/tests/acceptance/oidc-provider-test.js | 19 +-
ui/tests/helpers/oidc-config.js | 179 ++++++++
.../components/form-field-label-test.js | 4 +-
.../components/info-table-item-array-test.js | 31 +-
.../mfa-login-enforcement-header-test.js | 4 +-
.../components/oidc/assignment-form-test.js | 163 ++++++++
.../components/oidc/client-form-test.js | 251 +++++++++++
.../components/oidc/key-form-test.js | 204 +++++++++
.../components/oidc/provider-form-test.js | 224 ++++++++++
.../components/oidc/scope-form-test.js | 192 +++++++++
.../components/search-select-test.js | 385 ++++++++++++++++-
.../unit/adapters/oidc/assignment-test.js | 22 +
ui/tests/unit/adapters/oidc/client-test.js | 23 +
ui/tests/unit/adapters/oidc/key-test.js | 33 ++
ui/tests/unit/adapters/oidc/provider-test.js | 22 +
ui/tests/unit/adapters/oidc/scope-test.js | 22 +
ui/tests/unit/adapters/oidc/test-helper.js | 165 ++++++++
152 files changed, 6225 insertions(+), 211 deletions(-)
create mode 100644 changelog/17071.txt
create mode 100644 ui/app/adapters/named-path.js
create mode 100644 ui/app/adapters/oidc/assignment.js
create mode 100644 ui/app/adapters/oidc/client.js
create mode 100644 ui/app/adapters/oidc/key.js
create mode 100644 ui/app/adapters/oidc/provider.js
create mode 100644 ui/app/adapters/oidc/scope.js
create mode 100644 ui/app/components/oidc/assignment-form.js
create mode 100644 ui/app/components/oidc/client-form.js
create mode 100644 ui/app/components/oidc/key-form.js
create mode 100644 ui/app/components/oidc/provider-form.js
create mode 100644 ui/app/components/oidc/scope-form.js
create mode 100644 ui/app/controllers/vault/cluster/access/oidc.js
create mode 100644 ui/app/controllers/vault/cluster/access/oidc/assignments/assignment/details.js
create mode 100644 ui/app/controllers/vault/cluster/access/oidc/clients/client.js
create mode 100644 ui/app/controllers/vault/cluster/access/oidc/clients/client/details.js
create mode 100644 ui/app/controllers/vault/cluster/access/oidc/keys/key.js
create mode 100644 ui/app/controllers/vault/cluster/access/oidc/keys/key/details.js
create mode 100644 ui/app/controllers/vault/cluster/access/oidc/providers/provider.js
create mode 100644 ui/app/controllers/vault/cluster/access/oidc/providers/provider/details.js
create mode 100644 ui/app/controllers/vault/cluster/access/oidc/scopes/scope/details.js
create mode 100644 ui/app/models/oidc/assignment.js
create mode 100644 ui/app/models/oidc/client.js
create mode 100644 ui/app/models/oidc/key.js
create mode 100644 ui/app/models/oidc/provider.js
create mode 100644 ui/app/models/oidc/scope.js
create mode 100644 ui/app/routes/vault/cluster/access/oidc.js
create mode 100644 ui/app/routes/vault/cluster/access/oidc/assignments/assignment.js
create mode 100644 ui/app/routes/vault/cluster/access/oidc/assignments/assignment/details.js
create mode 100644 ui/app/routes/vault/cluster/access/oidc/assignments/assignment/edit.js
create mode 100644 ui/app/routes/vault/cluster/access/oidc/assignments/create.js
create mode 100644 ui/app/routes/vault/cluster/access/oidc/assignments/index.js
create mode 100644 ui/app/routes/vault/cluster/access/oidc/clients/client.js
create mode 100644 ui/app/routes/vault/cluster/access/oidc/clients/client/details.js
create mode 100644 ui/app/routes/vault/cluster/access/oidc/clients/client/edit.js
create mode 100644 ui/app/routes/vault/cluster/access/oidc/clients/client/providers.js
create mode 100644 ui/app/routes/vault/cluster/access/oidc/clients/create.js
create mode 100644 ui/app/routes/vault/cluster/access/oidc/clients/index.js
create mode 100644 ui/app/routes/vault/cluster/access/oidc/index.js
create mode 100644 ui/app/routes/vault/cluster/access/oidc/keys/create.js
create mode 100644 ui/app/routes/vault/cluster/access/oidc/keys/index.js
create mode 100644 ui/app/routes/vault/cluster/access/oidc/keys/key.js
create mode 100644 ui/app/routes/vault/cluster/access/oidc/keys/key/clients.js
create mode 100644 ui/app/routes/vault/cluster/access/oidc/keys/key/details.js
create mode 100644 ui/app/routes/vault/cluster/access/oidc/keys/key/edit.js
create mode 100644 ui/app/routes/vault/cluster/access/oidc/providers/create.js
create mode 100644 ui/app/routes/vault/cluster/access/oidc/providers/index.js
create mode 100644 ui/app/routes/vault/cluster/access/oidc/providers/provider.js
create mode 100644 ui/app/routes/vault/cluster/access/oidc/providers/provider/clients.js
create mode 100644 ui/app/routes/vault/cluster/access/oidc/providers/provider/details.js
create mode 100644 ui/app/routes/vault/cluster/access/oidc/providers/provider/edit.js
create mode 100644 ui/app/routes/vault/cluster/access/oidc/scopes/create.js
create mode 100644 ui/app/routes/vault/cluster/access/oidc/scopes/index.js
create mode 100644 ui/app/routes/vault/cluster/access/oidc/scopes/scope.js
create mode 100644 ui/app/routes/vault/cluster/access/oidc/scopes/scope/details.js
create mode 100644 ui/app/routes/vault/cluster/access/oidc/scopes/scope/edit.js
create mode 100644 ui/app/serializers/oidc/assignment.js
create mode 100644 ui/app/serializers/oidc/client.js
create mode 100644 ui/app/serializers/oidc/key.js
create mode 100644 ui/app/serializers/oidc/provider.js
create mode 100644 ui/app/serializers/oidc/scope.js
create mode 100644 ui/app/templates/components/oidc/assignment-form.hbs
create mode 100644 ui/app/templates/components/oidc/client-form.hbs
create mode 100644 ui/app/templates/components/oidc/client-list.hbs
create mode 100644 ui/app/templates/components/oidc/key-form.hbs
create mode 100644 ui/app/templates/components/oidc/provider-form.hbs
create mode 100644 ui/app/templates/components/oidc/provider-list.hbs
create mode 100644 ui/app/templates/components/oidc/scope-form.hbs
create mode 100644 ui/app/templates/vault/cluster/access/oidc.hbs
create mode 100644 ui/app/templates/vault/cluster/access/oidc/assignments/assignment/details.hbs
create mode 100644 ui/app/templates/vault/cluster/access/oidc/assignments/assignment/edit.hbs
create mode 100644 ui/app/templates/vault/cluster/access/oidc/assignments/create.hbs
create mode 100644 ui/app/templates/vault/cluster/access/oidc/assignments/index.hbs
create mode 100644 ui/app/templates/vault/cluster/access/oidc/clients/client.hbs
create mode 100644 ui/app/templates/vault/cluster/access/oidc/clients/client/details.hbs
create mode 100644 ui/app/templates/vault/cluster/access/oidc/clients/client/edit.hbs
create mode 100644 ui/app/templates/vault/cluster/access/oidc/clients/client/providers.hbs
create mode 100644 ui/app/templates/vault/cluster/access/oidc/clients/create.hbs
create mode 100644 ui/app/templates/vault/cluster/access/oidc/clients/index.hbs
create mode 100644 ui/app/templates/vault/cluster/access/oidc/index.hbs
create mode 100644 ui/app/templates/vault/cluster/access/oidc/keys/create.hbs
create mode 100644 ui/app/templates/vault/cluster/access/oidc/keys/index.hbs
create mode 100644 ui/app/templates/vault/cluster/access/oidc/keys/key.hbs
create mode 100644 ui/app/templates/vault/cluster/access/oidc/keys/key/clients.hbs
create mode 100644 ui/app/templates/vault/cluster/access/oidc/keys/key/details.hbs
create mode 100644 ui/app/templates/vault/cluster/access/oidc/keys/key/edit.hbs
create mode 100644 ui/app/templates/vault/cluster/access/oidc/providers/create.hbs
create mode 100644 ui/app/templates/vault/cluster/access/oidc/providers/index.hbs
create mode 100644 ui/app/templates/vault/cluster/access/oidc/providers/provider.hbs
create mode 100644 ui/app/templates/vault/cluster/access/oidc/providers/provider/clients.hbs
create mode 100644 ui/app/templates/vault/cluster/access/oidc/providers/provider/details.hbs
create mode 100644 ui/app/templates/vault/cluster/access/oidc/providers/provider/edit.hbs
create mode 100644 ui/app/templates/vault/cluster/access/oidc/scopes/create.hbs
create mode 100644 ui/app/templates/vault/cluster/access/oidc/scopes/index.hbs
create mode 100644 ui/app/templates/vault/cluster/access/oidc/scopes/scope/details.hbs
create mode 100644 ui/app/templates/vault/cluster/access/oidc/scopes/scope/edit.hbs
create mode 100644 ui/mirage/handlers/oidc-config.js
create mode 100644 ui/public/images/oidc-landing.png
create mode 100644 ui/tests/acceptance/oidc-config/clients-assignments-test.js
create mode 100644 ui/tests/acceptance/oidc-config/clients-keys-test.js
create mode 100644 ui/tests/acceptance/oidc-config/providers-scopes-test.js
create mode 100644 ui/tests/helpers/oidc-config.js
create mode 100644 ui/tests/integration/components/oidc/assignment-form-test.js
create mode 100644 ui/tests/integration/components/oidc/client-form-test.js
create mode 100644 ui/tests/integration/components/oidc/key-form-test.js
create mode 100644 ui/tests/integration/components/oidc/provider-form-test.js
create mode 100644 ui/tests/integration/components/oidc/scope-form-test.js
create mode 100644 ui/tests/unit/adapters/oidc/assignment-test.js
create mode 100644 ui/tests/unit/adapters/oidc/client-test.js
create mode 100644 ui/tests/unit/adapters/oidc/key-test.js
create mode 100644 ui/tests/unit/adapters/oidc/provider-test.js
create mode 100644 ui/tests/unit/adapters/oidc/scope-test.js
create mode 100644 ui/tests/unit/adapters/oidc/test-helper.js
diff --git a/changelog/17071.txt b/changelog/17071.txt
new file mode 100644
index 000000000..926ca839a
--- /dev/null
+++ b/changelog/17071.txt
@@ -0,0 +1,2 @@
+```release-note:feature
+**UI OIDC Provider Config**: Adds configuration of Vault as an OIDC identity provider, and offer Vault’s various authentication methods and source of identity to any client applications.
\ No newline at end of file
diff --git a/ui/app/adapters/named-path.js b/ui/app/adapters/named-path.js
new file mode 100644
index 000000000..383fa75b5
--- /dev/null
+++ b/ui/app/adapters/named-path.js
@@ -0,0 +1,76 @@
+/**
+ * base adapter for resources that are saved to a path whose unique identifier is name
+ * save requests are made to the same endpoint and the resource is either created if not found or updated
+ * */
+import ApplicationAdapter from './application';
+import { assert } from '@ember/debug';
+export default class NamedPathAdapter extends ApplicationAdapter {
+ namespace = 'v1';
+ saveMethod = 'POST'; // override when extending if PUT is used rather than POST
+
+ _saveRecord(store, { modelName }, snapshot) {
+ // since the response is empty return the serialized data rather than nothing
+ const data = store.serializerFor(modelName).serialize(snapshot);
+ return this.ajax(this.urlForUpdateRecord(snapshot.attr('name'), modelName, snapshot), this.saveMethod, {
+ data,
+ }).then(() => data);
+ }
+
+ // create does not return response similar to PUT request
+ createRecord() {
+ let [store, { modelName }, snapshot] = arguments;
+ let name = snapshot.attr('name');
+ // throw error if user attempts to create a record with same name, otherwise POST request silently overrides (updates) the existing model
+ if (store.hasRecordForId(modelName, name)) {
+ throw new Error(`A record already exists with the name: ${name}`);
+ } else {
+ return this._saveRecord(...arguments);
+ }
+ }
+
+ // update uses same endpoint and method as create
+ updateRecord() {
+ return this._saveRecord(...arguments);
+ }
+
+ // if backend does not return name in response Ember Data will throw an error for pushing a record with no id
+ // use the id (name) supplied to findRecord to set property on response data
+ findRecord(store, type, name) {
+ return super.findRecord(...arguments).then((resp) => {
+ if (!resp.data.name) {
+ resp.data.name = name;
+ }
+ return resp;
+ });
+ }
+
+ // GET request with list=true as query param
+ async query(store, type, query) {
+ const url = this.urlForQuery(query, type.modelName);
+ const { paramKey, filterFor, allowed_client_id } = query;
+ // * 'paramKey' is a string of the param name (model attr) we're filtering for, e.g. 'client_id'
+ // * 'filterFor' is an array of values to filter for (value type must match the attr type), e.g. array of ID strings
+ // * 'allowed_client_id' is a valid query param to the /provider endpoint
+ let queryParams = { list: true, ...(allowed_client_id && { allowed_client_id }) };
+ const response = await this.ajax(url, 'GET', { data: queryParams });
+
+ // filter LIST response only if key_info exists and query includes both 'paramKey' & 'filterFor'
+ if (filterFor) assert('filterFor must be an array', Array.isArray(filterFor));
+ if (response.data.key_info && filterFor && paramKey && !filterFor.includes('*')) {
+ const data = this.filterListResponse(paramKey, filterFor, response.data.key_info);
+ return { ...response, data };
+ }
+ return response;
+ }
+
+ filterListResponse(paramKey, matchValues, key_info) {
+ const keyInfoAsArray = Object.entries(key_info);
+ const filtered = keyInfoAsArray.filter((key) => {
+ const value = key[1]; // value is an object of model attributes
+ return matchValues.includes(value[paramKey]);
+ });
+ const filteredKeyInfo = Object.fromEntries(filtered);
+ const filteredKeys = Object.keys(filteredKeyInfo);
+ return { keys: filteredKeys, key_info: filteredKeyInfo };
+ }
+}
diff --git a/ui/app/adapters/oidc/assignment.js b/ui/app/adapters/oidc/assignment.js
new file mode 100644
index 000000000..0c78f6492
--- /dev/null
+++ b/ui/app/adapters/oidc/assignment.js
@@ -0,0 +1,7 @@
+import NamedPathAdapter from '../named-path';
+
+export default class OidcAssignmentAdapter extends NamedPathAdapter {
+ pathForType() {
+ return 'identity/oidc/assignment';
+ }
+}
diff --git a/ui/app/adapters/oidc/client.js b/ui/app/adapters/oidc/client.js
new file mode 100644
index 000000000..3331b6d7e
--- /dev/null
+++ b/ui/app/adapters/oidc/client.js
@@ -0,0 +1,7 @@
+import NamedPathAdapter from '../named-path';
+
+export default class OidcClientAdapter extends NamedPathAdapter {
+ pathForType() {
+ return 'identity/oidc/client';
+ }
+}
diff --git a/ui/app/adapters/oidc/key.js b/ui/app/adapters/oidc/key.js
new file mode 100644
index 000000000..0b7561ed0
--- /dev/null
+++ b/ui/app/adapters/oidc/key.js
@@ -0,0 +1,11 @@
+import NamedPathAdapter from '../named-path';
+
+export default class OidcKeyAdapter extends NamedPathAdapter {
+ pathForType() {
+ return 'identity/oidc/key';
+ }
+ rotate(name, verification_ttl) {
+ const data = verification_ttl ? { verification_ttl } : {};
+ return this.ajax(`${this.urlForUpdateRecord(name, 'oidc/key')}/rotate`, 'POST', { data });
+ }
+}
diff --git a/ui/app/adapters/oidc/provider.js b/ui/app/adapters/oidc/provider.js
new file mode 100644
index 000000000..064e56968
--- /dev/null
+++ b/ui/app/adapters/oidc/provider.js
@@ -0,0 +1,7 @@
+import NamedPathAdapter from '../named-path';
+
+export default class OidcProviderAdapter extends NamedPathAdapter {
+ pathForType() {
+ return 'identity/oidc/provider';
+ }
+}
diff --git a/ui/app/adapters/oidc/scope.js b/ui/app/adapters/oidc/scope.js
new file mode 100644
index 000000000..af69799d8
--- /dev/null
+++ b/ui/app/adapters/oidc/scope.js
@@ -0,0 +1,7 @@
+import NamedPathAdapter from '../named-path';
+
+export default class OidcScopeAdapter extends NamedPathAdapter {
+ pathForType() {
+ return 'identity/oidc/scope';
+ }
+}
diff --git a/ui/app/components/oidc/assignment-form.js b/ui/app/components/oidc/assignment-form.js
new file mode 100644
index 000000000..d521bce93
--- /dev/null
+++ b/ui/app/components/oidc/assignment-form.js
@@ -0,0 +1,72 @@
+import Component from '@glimmer/component';
+import { action } from '@ember/object';
+import { inject as service } from '@ember/service';
+import { task } from 'ember-concurrency';
+import { tracked } from '@glimmer/tracking';
+
+/**
+ * @module Oidc::AssignmentForm
+ * Oidc::AssignmentForm components are used to display the create view for OIDC providers assignments.
+ *
+ * @example
+ * ```js
+ *
+ * ```
+ * @callback onCancel
+ * @callback onSave
+ * @param {object} model - The parent's model
+ * @param {string} onCancel - callback triggered when cancel button is clicked
+ * @param {string} onSave - callback triggered when save button is clicked
+ */
+
+export default class OidcAssignmentFormComponent extends Component {
+ @service store;
+ @service flashMessages;
+ @tracked modelValidations;
+ @tracked errorBanner;
+
+ @task
+ *save(event) {
+ event.preventDefault();
+ try {
+ const { isValid, state } = this.args.model.validate();
+ this.modelValidations = isValid ? null : state;
+ if (isValid) {
+ const { isNew, name } = this.args.model;
+ yield this.args.model.save();
+ this.flashMessages.success(`Successfully ${isNew ? 'created' : 'updated'} the assignment ${name}.`);
+ // this form is sometimes used in modal, passing the model notifies
+ // the parent if the save was successful
+ this.args.onSave(this.args.model);
+ }
+ } catch (error) {
+ const message = error.errors ? error.errors.join('. ') : error.message;
+ this.errorBanner = message;
+ }
+ }
+
+ @action
+ cancel() {
+ const method = this.args.model.isNew ? 'unloadRecord' : 'rollbackAttributes';
+ this.args.model[method]();
+ this.args.onCancel();
+ }
+
+ @action
+ handleOperation({ target }) {
+ this.args.model.name = target.value;
+ }
+
+ @action
+ onEntitiesSelect(selectedIds) {
+ this.args.model.entityIds = selectedIds;
+ }
+
+ @action
+ onGroupsSelect(selectedIds) {
+ this.args.model.groupIds = selectedIds;
+ }
+}
diff --git a/ui/app/components/oidc/client-form.js b/ui/app/components/oidc/client-form.js
new file mode 100644
index 000000000..2de6cf92a
--- /dev/null
+++ b/ui/app/components/oidc/client-form.js
@@ -0,0 +1,91 @@
+import Component from '@glimmer/component';
+import { action } from '@ember/object';
+import { inject as service } from '@ember/service';
+import { tracked } from '@glimmer/tracking';
+import { task } from 'ember-concurrency';
+/**
+ * @module OidcClientForm
+ * OidcClientForm components are used to create and update OIDC clients (a.k.a. applications)
+ *
+ * @example
+ * ```js
+ *
+ * ```
+ * @callback onCancel
+ * @callback onSave
+ * @param {Object} model - oidc client model
+ * @param {onCancel} onCancel - callback triggered when cancel button is clicked
+ * @param {onSave} onSave - callback triggered on save success
+ */
+
+export default class OidcClientForm extends Component {
+ @service store;
+ @service flashMessages;
+ @tracked modelValidations;
+ @tracked errorBanner;
+ @tracked invalidFormAlert;
+ @tracked radioCardGroupValue =
+ !this.args.model.assignments || this.args.model.assignments.includes('allow_all')
+ ? 'allow_all'
+ : 'limited';
+
+ get modelAssignments() {
+ const { assignments } = this.args.model;
+ if (assignments.includes('allow_all') && assignments.length === 1) {
+ return [];
+ } else {
+ return assignments;
+ }
+ }
+
+ @action
+ handleAssignmentSelection(selection) {
+ // if array then coming from search-select component, set selection as model assignments
+ if (Array.isArray(selection)) {
+ this.args.model.assignments = selection;
+ } else {
+ // otherwise update radio button value and reset assignments so
+ // UI always reflects a user's selection (including when no assignments are selected)
+ this.radioCardGroupValue = selection;
+ this.args.model.assignments = [];
+ }
+ }
+
+ @action
+ cancel() {
+ const method = this.args.model.isNew ? 'unloadRecord' : 'rollbackAttributes';
+ this.args.model[method]();
+ this.args.onCancel();
+ }
+
+ @task
+ *save(event) {
+ event.preventDefault();
+ try {
+ const { isValid, state, invalidFormMessage } = this.args.model.validate();
+ this.modelValidations = isValid ? null : state;
+ this.invalidFormAlert = invalidFormMessage;
+ if (isValid) {
+ if (this.radioCardGroupValue === 'allow_all') {
+ // the backend permits 'allow_all' AND other assignments, though 'allow_all' will take precedence
+ // the UI limits the config by allowing either 'allow_all' OR a list of other assignments
+ // note: when editing the UI removes any additional assignments previously configured via CLI
+ this.args.model.assignments = ['allow_all'];
+ }
+ // if TTL components are toggled off, set to default lease duration
+ const { idTokenTtl, accessTokenTtl } = this.args.model;
+ // value returned from API is a number, and string when from form action
+ if (Number(idTokenTtl) === 0) this.args.model.idTokenTtl = '24h';
+ if (Number(accessTokenTtl) === 0) this.args.model.accessTokenTtl = '24h';
+ const { isNew, name } = this.args.model;
+ yield this.args.model.save();
+ this.flashMessages.success(`Successfully ${isNew ? 'created' : 'updated'} the application ${name}.`);
+ this.args.onSave();
+ }
+ } catch (error) {
+ const message = error.errors ? error.errors.join('. ') : error.message;
+ this.errorBanner = message;
+ this.invalidFormAlert = 'There was an error submitting this form.';
+ }
+ }
+}
diff --git a/ui/app/components/oidc/key-form.js b/ui/app/components/oidc/key-form.js
new file mode 100644
index 000000000..3fe518cf0
--- /dev/null
+++ b/ui/app/components/oidc/key-form.js
@@ -0,0 +1,89 @@
+import Component from '@glimmer/component';
+import { action } from '@ember/object';
+import { inject as service } from '@ember/service';
+import { tracked } from '@glimmer/tracking';
+import { task } from 'ember-concurrency';
+
+/**
+ * @module OidcKeyForm
+ * OidcKeyForm components are used to create and update OIDC providers
+ *
+ * @example
+ * ```js
+ *
+ * ```
+ * @callback onCancel
+ * @callback onSave
+ * @param {Object} model - oidc client model
+ * @param {onCancel} onCancel - callback triggered when cancel button is clicked
+ * @param {onSave} onSave - callback triggered on save success
+ */
+
+export default class OidcKeyForm extends Component {
+ @service store;
+ @service flashMessages;
+ @tracked errorBanner;
+ @tracked invalidFormAlert;
+ @tracked modelValidations;
+ @tracked radioCardGroupValue =
+ // If "*" is provided, all clients are allowed: https://www.vaultproject.io/api-docs/secret/identity/oidc-provider#parameters
+ !this.args.model.allowedClientIds || this.args.model.allowedClientIds.includes('*')
+ ? 'allow_all'
+ : 'limited';
+
+ get filterDropdownOptions() {
+ // query object sent to search-select so only clients that reference this key appear in dropdown
+ return { paramKey: 'key', filterFor: [this.args.model.name] };
+ }
+
+ @action
+ handleClientSelection(selection) {
+ // if array then coming from search-select component, set selection as model clients
+ if (Array.isArray(selection)) {
+ this.args.model.allowedClientIds = selection.map((client) => client.clientId);
+ } else {
+ // otherwise update radio button value and reset clients so
+ // UI always reflects a user's selection (including when no clients are selected)
+ this.radioCardGroupValue = selection;
+ this.args.model.allowedClientIds = [];
+ }
+ }
+
+ @action
+ cancel() {
+ const method = this.args.model.isNew ? 'unloadRecord' : 'rollbackAttributes';
+ this.args.model[method]();
+ this.args.onCancel();
+ }
+
+ @task
+ *save(event) {
+ event.preventDefault();
+ try {
+ const { isValid, state, invalidFormMessage } = this.args.model.validate();
+ this.modelValidations = isValid ? null : state;
+ this.invalidFormAlert = invalidFormMessage;
+ if (isValid) {
+ const { isNew, name } = this.args.model;
+ if (this.radioCardGroupValue === 'allow_all') {
+ this.args.model.allowedClientIds = ['*'];
+ }
+ // if TTL components are toggled off, set to default lease duration
+ const { rotationPeriod, verificationTtl } = this.args.model;
+ // value returned from API is a number, and string when from form action
+ if (Number(rotationPeriod) === 0) this.args.model.rotationPeriod = '24h';
+ if (Number(verificationTtl) === 0) this.args.model.verificationTtl = '24h';
+ yield this.args.model.save();
+ this.flashMessages.success(
+ `Successfully ${isNew ? 'created' : 'updated'} the key
+ ${name}.`
+ );
+ this.args.onSave();
+ }
+ } catch (error) {
+ const message = error.errors ? error.errors.join('. ') : error.message;
+ this.errorBanner = message;
+ this.invalidFormAlert = 'There was an error submitting this form.';
+ }
+ }
+}
diff --git a/ui/app/components/oidc/provider-form.js b/ui/app/components/oidc/provider-form.js
new file mode 100644
index 000000000..c51754da8
--- /dev/null
+++ b/ui/app/components/oidc/provider-form.js
@@ -0,0 +1,85 @@
+import Component from '@glimmer/component';
+import { action } from '@ember/object';
+import { inject as service } from '@ember/service';
+import { tracked } from '@glimmer/tracking';
+import { task } from 'ember-concurrency';
+import parseURL from 'core/utils/parse-url';
+/**
+ * @module OidcProviderForm
+ * OidcProviderForm components are used to create and update OIDC providers
+ *
+ * @example
+ * ```js
+ *
+ * ```
+ * @callback onCancel
+ * @callback onSave
+ * @param {Object} model - oidc client model
+ * @param {onCancel} onCancel - callback triggered when cancel button is clicked
+ * @param {onSave} onSave - callback triggered on save success
+ */
+
+export default class OidcProviderForm extends Component {
+ @service store;
+ @service flashMessages;
+ @tracked modelValidations;
+ @tracked errorBanner;
+ @tracked invalidFormAlert;
+ @tracked radioCardGroupValue =
+ // If "*" is provided, all clients are allowed: https://www.vaultproject.io/api-docs/secret/identity/oidc-provider#parameters
+ !this.args.model.allowedClientIds || this.args.model.allowedClientIds.includes('*')
+ ? 'allow_all'
+ : 'limited';
+
+ constructor() {
+ super(...arguments);
+ const { model } = this.args;
+ model.issuer = model.isNew ? '' : parseURL(model.issuer).origin;
+ }
+
+ @action
+ handleClientSelection(selection) {
+ // if array then coming from search-select component, set selection as model clients
+ if (Array.isArray(selection)) {
+ this.args.model.allowedClientIds = selection.map((client) => client.clientId);
+ } else {
+ // otherwise update radio button value and reset clients so
+ // UI always reflects a user's selection (including when no clients are selected)
+ this.radioCardGroupValue = selection;
+ this.args.model.allowedClientIds = [];
+ }
+ }
+
+ @action
+ cancel() {
+ const method = this.args.model.isNew ? 'unloadRecord' : 'rollbackAttributes';
+ this.args.model[method]();
+ this.args.onCancel();
+ }
+
+ @task
+ *save(event) {
+ event.preventDefault();
+ try {
+ const { isValid, state, invalidFormMessage } = this.args.model.validate();
+ this.modelValidations = isValid ? null : state;
+ this.invalidFormAlert = invalidFormMessage;
+ if (isValid) {
+ const { isNew, name } = this.args.model;
+ if (this.radioCardGroupValue === 'allow_all') {
+ this.args.model.allowedClientIds = ['*'];
+ }
+ yield this.args.model.save();
+ this.flashMessages.success(
+ `Successfully ${isNew ? 'created' : 'updated'} the OIDC provider
+ ${name}.`
+ );
+ this.args.onSave();
+ }
+ } catch (error) {
+ const message = error.errors ? error.errors.join('. ') : error.message;
+ this.errorBanner = message;
+ this.invalidFormAlert = 'There was an error submitting this form.';
+ }
+ }
+}
diff --git a/ui/app/components/oidc/scope-form.js b/ui/app/components/oidc/scope-form.js
new file mode 100644
index 000000000..0e1c3a939
--- /dev/null
+++ b/ui/app/components/oidc/scope-form.js
@@ -0,0 +1,62 @@
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { action } from '@ember/object';
+import { task } from 'ember-concurrency';
+import { inject as service } from '@ember/service';
+
+/**
+ * @module OidcScopeForm
+ * Oidc scope form components are used to create and edit oidc scopes
+ *
+ * @example
+ * ```js
+ *
+ * ```
+ * @callback onCancel
+ * @callback onSave
+ * @param {Object} model - oidc scope model
+ * @param {onCancel} onCancel - callback triggered when cancel button is clicked
+ * @param {onSave} onSave - callback triggered on save success
+ */
+
+export default class OidcScopeFormComponent extends Component {
+ @service flashMessages;
+ @tracked errorBanner;
+ @tracked invalidFormAlert;
+ @tracked modelValidations;
+ // formatting here is purposeful so that whitespace renders correctly in JsonEditor
+ exampleTemplate = `{
+ "username": {{identity.entity.aliases.$MOUNT_ACCESSOR.name}},
+ "contact": {
+ "email": {{identity.entity.metadata.email}},
+ "phone_number": {{identity.entity.metadata.phone_number}}
+ },
+ "groups": {{identity.entity.groups.names}}
+}`;
+
+ @task
+ *save(event) {
+ event.preventDefault();
+ try {
+ const { isValid, state, invalidFormMessage } = this.args.model.validate();
+ this.modelValidations = isValid ? null : state;
+ this.invalidFormAlert = invalidFormMessage;
+ if (isValid) {
+ const { isNew, name } = this.args.model;
+ yield this.args.model.save();
+ this.flashMessages.success(`Successfully ${isNew ? 'created' : 'updated'} the scope ${name}.`);
+ this.args.onSave();
+ }
+ } catch (error) {
+ const message = error.errors ? error.errors.join('. ') : error.message;
+ this.errorBanner = message;
+ this.invalidFormAlert = 'There was an error submitting this form.';
+ }
+ }
+ @action
+ cancel() {
+ const method = this.args.model.isNew ? 'unloadRecord' : 'rollbackAttributes';
+ this.args.model[method]();
+ this.args.onCancel();
+ }
+}
diff --git a/ui/app/components/regex-validator.hbs b/ui/app/components/regex-validator.hbs
index 10b3a36af..6576703c0 100644
--- a/ui/app/components/regex-validator.hbs
+++ b/ui/app/components/regex-validator.hbs
@@ -16,9 +16,9 @@
{{@attr.options.subText}}
{{#if @attr.options.docLink}}
-
+
See our documentation
-
+
for help.
{{/if}}
diff --git a/ui/app/controllers/vault/cluster/access/oidc.js b/ui/app/controllers/vault/cluster/access/oidc.js
new file mode 100644
index 000000000..c6ef6dd70
--- /dev/null
+++ b/ui/app/controllers/vault/cluster/access/oidc.js
@@ -0,0 +1,34 @@
+import Controller from '@ember/controller';
+import { inject as service } from '@ember/service';
+import { tracked } from '@glimmer/tracking';
+
+export default class OidcConfigureController extends Controller {
+ @service router;
+
+ @tracked header = null;
+
+ constructor() {
+ super(...arguments);
+ this.router.on('routeDidChange', (transition) => this.setHeader(transition));
+ }
+
+ setHeader(transition) {
+ // set correct header state based on child route
+ // when no clients have been created, display create button as call to action
+ // list views share the same header with tabs as resource links
+ // the remaining routes are responsible for their own header
+ const routeName = transition.to.name;
+ if (routeName.includes('oidc.index')) {
+ this.header = 'cta';
+ } else {
+ const isList = ['clients', 'assignments', 'keys', 'scopes', 'providers'].find((resource) => {
+ return routeName.includes(`${resource}.index`);
+ });
+ this.header = isList ? 'list' : null;
+ }
+ }
+
+ get isCta() {
+ return this.header === 'cta';
+ }
+}
diff --git a/ui/app/controllers/vault/cluster/access/oidc/assignments/assignment/details.js b/ui/app/controllers/vault/cluster/access/oidc/assignments/assignment/details.js
new file mode 100644
index 000000000..bf21dc72f
--- /dev/null
+++ b/ui/app/controllers/vault/cluster/access/oidc/assignments/assignment/details.js
@@ -0,0 +1,21 @@
+import Controller from '@ember/controller';
+import { action } from '@ember/object';
+import { inject as service } from '@ember/service';
+
+export default class OidcAssignmentDetailsController extends Controller {
+ @service router;
+ @service flashMessages;
+
+ @action
+ async delete() {
+ try {
+ await this.model.destroyRecord();
+ this.flashMessages.success('Assignment deleted successfully');
+ this.router.transitionTo('vault.cluster.access.oidc.assignments');
+ } catch (error) {
+ this.model.rollbackAttributes();
+ const message = error.errors ? error.errors.join('. ') : error.message;
+ this.flashMessages.danger(message);
+ }
+ }
+}
diff --git a/ui/app/controllers/vault/cluster/access/oidc/clients/client.js b/ui/app/controllers/vault/cluster/access/oidc/clients/client.js
new file mode 100644
index 000000000..4e3e0c148
--- /dev/null
+++ b/ui/app/controllers/vault/cluster/access/oidc/clients/client.js
@@ -0,0 +1,21 @@
+import Controller from '@ember/controller';
+import { inject as service } from '@ember/service';
+import { tracked } from '@glimmer/tracking';
+
+export default class OidcClientController extends Controller {
+ @service router;
+ @tracked isEditRoute;
+
+ constructor() {
+ super(...arguments);
+ this.router.on(
+ 'routeDidChange',
+ ({ targetName }) => (this.isEditRoute = targetName.includes('edit') ? true : false)
+ );
+ }
+
+ get showHeader() {
+ // hide header when rendering the edit form
+ return !this.isEditRoute;
+ }
+}
diff --git a/ui/app/controllers/vault/cluster/access/oidc/clients/client/details.js b/ui/app/controllers/vault/cluster/access/oidc/clients/client/details.js
new file mode 100644
index 000000000..55a8b296e
--- /dev/null
+++ b/ui/app/controllers/vault/cluster/access/oidc/clients/client/details.js
@@ -0,0 +1,21 @@
+import Controller from '@ember/controller';
+import { action } from '@ember/object';
+import { inject as service } from '@ember/service';
+
+export default class OidcClientDetailsController extends Controller {
+ @service router;
+ @service flashMessages;
+
+ @action
+ async delete() {
+ try {
+ await this.model.destroyRecord();
+ this.flashMessages.success('Application deleted successfully');
+ this.router.transitionTo('vault.cluster.access.oidc.clients');
+ } catch (error) {
+ this.model.rollbackAttributes();
+ const message = error.errors ? error.errors.join('. ') : error.message;
+ this.flashMessages.danger(message);
+ }
+ }
+}
diff --git a/ui/app/controllers/vault/cluster/access/oidc/keys/key.js b/ui/app/controllers/vault/cluster/access/oidc/keys/key.js
new file mode 100644
index 000000000..1658fda3a
--- /dev/null
+++ b/ui/app/controllers/vault/cluster/access/oidc/keys/key.js
@@ -0,0 +1,20 @@
+import Controller from '@ember/controller';
+import { inject as service } from '@ember/service';
+import { tracked } from '@glimmer/tracking';
+
+export default class OidcKeyController extends Controller {
+ @service router;
+ @tracked isEditRoute;
+
+ constructor() {
+ super(...arguments);
+ this.router.on('routeDidChange', ({ targetName }) => {
+ return (this.isEditRoute = targetName.includes('edit') ? true : false);
+ });
+ }
+
+ get showHeader() {
+ // hide header when rendering the edit form
+ return !this.isEditRoute;
+ }
+}
diff --git a/ui/app/controllers/vault/cluster/access/oidc/keys/key/details.js b/ui/app/controllers/vault/cluster/access/oidc/keys/key/details.js
new file mode 100644
index 000000000..de844e1d7
--- /dev/null
+++ b/ui/app/controllers/vault/cluster/access/oidc/keys/key/details.js
@@ -0,0 +1,36 @@
+import Controller from '@ember/controller';
+import { action } from '@ember/object';
+import { inject as service } from '@ember/service';
+import { task } from 'ember-concurrency';
+import { waitFor } from '@ember/test-waiters';
+
+export default class OidcKeyDetailsController extends Controller {
+ @service router;
+ @service flashMessages;
+
+ @task
+ @waitFor
+ *rotateKey() {
+ const adapter = this.store.adapterFor('oidc/key');
+ yield adapter
+ .rotate(this.model.name, this.model.verificationTtl)
+ .then(() => {
+ this.flashMessages.success(`Success: ${this.model.name} connection was rotated.`);
+ })
+ .catch((e) => {
+ this.flashMessages.danger(e.errors);
+ });
+ }
+ @action
+ async delete() {
+ try {
+ await this.model.destroyRecord();
+ this.flashMessages.success('Key deleted successfully');
+ this.router.transitionTo('vault.cluster.access.oidc.keys');
+ } catch (error) {
+ this.model.rollbackAttributes();
+ const message = error.errors ? error.errors.join('. ') : error.message;
+ this.flashMessages.danger(message);
+ }
+ }
+}
diff --git a/ui/app/controllers/vault/cluster/access/oidc/providers/provider.js b/ui/app/controllers/vault/cluster/access/oidc/providers/provider.js
new file mode 100644
index 000000000..a0b1b295b
--- /dev/null
+++ b/ui/app/controllers/vault/cluster/access/oidc/providers/provider.js
@@ -0,0 +1,20 @@
+import Controller from '@ember/controller';
+import { inject as service } from '@ember/service';
+import { tracked } from '@glimmer/tracking';
+
+export default class OidcProviderController extends Controller {
+ @service router;
+ @tracked isEditRoute;
+
+ constructor() {
+ super(...arguments);
+ this.router.on('routeDidChange', ({ targetName }) => {
+ return (this.isEditRoute = targetName.includes('edit') ? true : false);
+ });
+ }
+
+ get showHeader() {
+ // hide header when rendering the edit form
+ return !this.isEditRoute;
+ }
+}
diff --git a/ui/app/controllers/vault/cluster/access/oidc/providers/provider/details.js b/ui/app/controllers/vault/cluster/access/oidc/providers/provider/details.js
new file mode 100644
index 000000000..6a15ef2e3
--- /dev/null
+++ b/ui/app/controllers/vault/cluster/access/oidc/providers/provider/details.js
@@ -0,0 +1,21 @@
+import Controller from '@ember/controller';
+import { action } from '@ember/object';
+import { inject as service } from '@ember/service';
+
+export default class OidcProviderDetailsController extends Controller {
+ @service router;
+ @service flashMessages;
+
+ @action
+ async delete() {
+ try {
+ await this.model.destroyRecord();
+ this.flashMessages.success('Provider deleted successfully');
+ this.router.transitionTo('vault.cluster.access.oidc.providers');
+ } catch (error) {
+ this.model.rollbackAttributes();
+ const message = error.errors ? error.errors.join('. ') : error.message;
+ this.flashMessages.danger(message);
+ }
+ }
+}
diff --git a/ui/app/controllers/vault/cluster/access/oidc/scopes/scope/details.js b/ui/app/controllers/vault/cluster/access/oidc/scopes/scope/details.js
new file mode 100644
index 000000000..1934ae082
--- /dev/null
+++ b/ui/app/controllers/vault/cluster/access/oidc/scopes/scope/details.js
@@ -0,0 +1,21 @@
+import Controller from '@ember/controller';
+import { action } from '@ember/object';
+import { inject as service } from '@ember/service';
+
+export default class OidcScopeDetailsController extends Controller {
+ @service router;
+ @service flashMessages;
+
+ @action
+ async delete() {
+ try {
+ await this.model.destroyRecord();
+ this.flashMessages.success('Scope deleted successfully');
+ this.router.transitionTo('vault.cluster.access.oidc.scopes');
+ } catch (error) {
+ this.model.rollbackAttributes();
+ const message = error.errors ? error.errors.join('. ') : error.message;
+ this.flashMessages.danger(message);
+ }
+ }
+}
diff --git a/ui/app/decorators/model-validations.js b/ui/app/decorators/model-validations.js
index 8520811da..1db84446d 100644
--- a/ui/app/decorators/model-validations.js
+++ b/ui/app/decorators/model-validations.js
@@ -22,7 +22,7 @@ import { get } from '@ember/object';
* state represents the error state of the properties defined in the validations object
* const { isValid, errors } = state[propertyKeyName];
* isValid represents the validity of the property
- * errors will be populated with messages defined in the validations object when validations fail
+ * errors will be populated with messages defined in the validations object when validations fail. message must be a complete sentence (and include punctuation)
* since a property can have multiple validations, errors is always returned as an array
*
*** basic example
@@ -30,7 +30,8 @@ import { get } from '@ember/object';
* import Model from '@ember-data/model';
* import withModelValidations from 'vault/decorators/model-validations';
*
- * const validations = { foo: [{ type: 'presence', message: 'foo is a required field' }] };
+ * Notes: all messages need to have a period at the end of them.
+ * const validations = { foo: [{ type: 'presence', message: 'foo is a required field.' }] };
* @withModelValidations(validations)
* class SomeModel extends Model { foo = null; }
*
@@ -42,7 +43,7 @@ import { get } from '@ember/object';
*
*** example using custom validator
*
- * const validations = { foo: [{ validator: (model) => model.bar.includes('test') ? model.foo : false, message: 'foo is required if bar includes test' }] };
+ * const validations = { foo: [{ validator: (model) => model.bar.includes('test') ? model.foo : false, message: 'foo is required if bar includes test.' }] };
* @withModelValidations(validations)
* class SomeModel extends Model { foo = false; bar = ['foo', 'baz']; }
*
@@ -50,7 +51,11 @@ import { get } from '@ember/object';
* const { isValid, state } = model.validate();
* -> isValid = false;
* -> state.foo.isValid = false;
- * -> state.foo.errors = ['foo is required if bar includes test'];
+ * -> state.foo.errors = ['foo is required if bar includes test.'];
+ *
+ * *** example adding class in hbs file
+ * all form-validations need to have a red border around them. Add this by adding a conditional class 'has-error-border'
+ * class="input field {{if this.errors.name.errors 'has-error-border'}}"
*/
export function withModelValidations(validations) {
diff --git a/ui/app/models/database/connection.js b/ui/app/models/database/connection.js
index d1fc76290..143882dca 100644
--- a/ui/app/models/database/connection.js
+++ b/ui/app/models/database/connection.js
@@ -54,7 +54,7 @@ export default Model.extend({
defaultSubText:
'Unless a custom policy is specified, Vault will use a default: 20 characters with at least 1 uppercase, 1 lowercase, 1 number, and 1 dash character.',
defaultShown: 'Default',
- docLink: 'https://www.vaultproject.io/docs/concepts/password-policies',
+ docLink: '/docs/concepts/password-policies',
}),
// common fields
@@ -106,7 +106,7 @@ export default Model.extend({
subText: 'Enter the custom username template to use.',
defaultSubText:
'Template describing how dynamic usernames are generated. Vault will use the default for this plugin.',
- docLink: 'https://www.vaultproject.io/docs/concepts/username-templating',
+ docLink: '/docs/concepts/username-templating',
defaultShown: 'Default',
}),
max_open_connections: attr('number', {
diff --git a/ui/app/models/oidc/assignment.js b/ui/app/models/oidc/assignment.js
new file mode 100644
index 000000000..980c5f62d
--- /dev/null
+++ b/ui/app/models/oidc/assignment.js
@@ -0,0 +1,59 @@
+import Model, { attr } from '@ember-data/model';
+import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities';
+import { withModelValidations } from 'vault/decorators/model-validations';
+import { isPresent } from '@ember/utils';
+
+const validations = {
+ name: [
+ { type: 'presence', message: 'Name is required.' },
+ {
+ type: 'containsWhiteSpace',
+ message: 'Name cannot contain whitespace.',
+ },
+ ],
+ targets: [
+ {
+ validator(model) {
+ return isPresent(model.entityIds) || isPresent(model.groupIds);
+ },
+ message: 'At least one entity or group is required.',
+ },
+ ],
+};
+
+@withModelValidations(validations)
+export default class OidcAssignmentModel extends Model {
+ @attr('string') name;
+ @attr('array') entityIds;
+ @attr('array') groupIds;
+
+ // CAPABILITIES
+ @lazyCapabilities(apiPath`identity/oidc/assignment/${'name'}`, 'name') assignmentPath;
+ @lazyCapabilities(apiPath`identity/oidc/assignment`) assignmentsPath;
+
+ get canCreate() {
+ return this.assignmentPath.get('canCreate');
+ }
+ get canRead() {
+ return this.assignmentPath.get('canRead');
+ }
+ get canEdit() {
+ return this.assignmentPath.get('canUpdate');
+ }
+ get canDelete() {
+ return this.assignmentPath.get('canDelete');
+ }
+ get canList() {
+ return this.assignmentsPath.get('canList');
+ }
+
+ @lazyCapabilities(apiPath`identity/entity`) entitiesPath;
+ get canListEntities() {
+ return this.entitiesPath.get('canList');
+ }
+
+ @lazyCapabilities(apiPath`identity/group`) groupsPath;
+ get canListGroups() {
+ return this.groupsPath.get('canList');
+ }
+}
diff --git a/ui/app/models/oidc/client.js b/ui/app/models/oidc/client.js
new file mode 100644
index 000000000..89edafdcd
--- /dev/null
+++ b/ui/app/models/oidc/client.js
@@ -0,0 +1,134 @@
+import Model, { attr } from '@ember-data/model';
+import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities';
+import { expandAttributeMeta } from 'vault/utils/field-to-attrs';
+import fieldToAttrs from 'vault/utils/field-to-attrs';
+import { withModelValidations } from 'vault/decorators/model-validations';
+
+const validations = {
+ name: [
+ { type: 'presence', message: 'Name is required.' },
+ {
+ type: 'containsWhiteSpace',
+ message: 'Name cannot contain whitespace.',
+ },
+ ],
+ key: [{ type: 'presence', message: 'Key is required.' }],
+};
+
+@withModelValidations(validations)
+export default class OidcClientModel extends Model {
+ @attr('string', { label: 'Application name', editDisabled: true }) name;
+ @attr('string', {
+ label: 'Type',
+ subText:
+ 'Specify whether the application type is confidential or public. The public type must use PKCE. This cannot be edited later.',
+ editType: 'radio',
+ editDisabled: true,
+ defaultValue: 'confidential',
+ possibleValues: ['confidential', 'public'],
+ })
+ clientType;
+
+ @attr('array', {
+ label: 'Redirect URIs',
+ subText:
+ 'One of these values must exactly match the redirect_uri parameter value used in each authentication request.',
+ editType: 'stringArray',
+ })
+ redirectUris;
+
+ // >> MORE OPTIONS TOGGLE <<
+
+ @attr('string', {
+ label: 'Signing key',
+ subText: 'Add a key to sign and verify the JSON web tokens (JWT). This cannot be edited later.',
+ editType: 'searchSelect',
+ editDisabled: true,
+ onlyAllowExisting: true,
+ defaultValue() {
+ return ['default'];
+ },
+ fallbackComponent: 'input-search',
+ selectLimit: 1,
+ models: ['oidc/key'],
+ })
+ key;
+ @attr({
+ label: 'Access Token TTL',
+ editType: 'ttl',
+ defaultValue: '24h',
+ })
+ accessTokenTtl;
+
+ @attr({
+ label: 'ID Token TTL',
+ editType: 'ttl',
+ defaultValue: '24h',
+ })
+ idTokenTtl;
+
+ // >> END MORE OPTIONS TOGGLE <<
+
+ @attr('array', { label: 'Assign access' }) assignments; // no editType because does not use form-field component
+ @attr('string', { label: 'Client ID' }) clientId;
+ @attr('string') clientSecret;
+
+ // CAPABILITIES //
+ @lazyCapabilities(apiPath`identity/oidc/client/${'name'}`, 'name') clientPath;
+ @lazyCapabilities(apiPath`identity/oidc/client`) clientsPath;
+ get canCreate() {
+ return this.clientPath.get('canCreate');
+ }
+ get canRead() {
+ return this.clientPath.get('canRead');
+ }
+ get canEdit() {
+ return this.clientPath.get('canUpdate');
+ }
+ get canDelete() {
+ return this.clientPath.get('canDelete');
+ }
+ get canList() {
+ return this.clientsPath.get('canList');
+ }
+
+ @lazyCapabilities(apiPath`identity/oidc/key`) keysPath;
+ get canListKeys() {
+ return this.keysPath.get('canList');
+ }
+
+ @lazyCapabilities(apiPath`identity/oidc/assignment/${'name'}`, 'name') assignmentPath;
+ @lazyCapabilities(apiPath`identity/oidc/assignment`) assignmentsPath;
+ get canCreateAssignments() {
+ return this.assignmentPath.get('canCreate');
+ }
+ get canListAssignments() {
+ return this.assignmentsPath.get('canList');
+ }
+
+ // API WIP
+ @lazyCapabilities(apiPath`identity/oidc/${'name'}/provider`, 'backend', 'name') clientProvidersPath;
+ get canListProviders() {
+ return this.clientProvidersPath.get('canList');
+ }
+
+ // TODO refactor when field-to-attrs util is refactored as decorator
+ _attributeMeta = null; // cache initial result of expandAttributeMeta in getter and return
+ get formFields() {
+ if (!this._attributeMeta) {
+ this._attributeMeta = expandAttributeMeta(this, ['name', 'clientType', 'redirectUris']);
+ }
+ return this._attributeMeta;
+ }
+
+ _fieldToAttrsGroups = null;
+ // more options fields
+ get fieldGroups() {
+ if (!this._fieldToAttrsGroups) {
+ this._fieldToAttrsGroups = fieldToAttrs(this, [
+ { 'More options': ['key', 'idTokenTtl', 'accessTokenTtl'] },
+ ]);
+ }
+ return this._fieldToAttrsGroups;
+ }
+}
diff --git a/ui/app/models/oidc/key.js b/ui/app/models/oidc/key.js
new file mode 100644
index 000000000..843516d7e
--- /dev/null
+++ b/ui/app/models/oidc/key.js
@@ -0,0 +1,64 @@
+import Model, { attr } from '@ember-data/model';
+import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities';
+import { expandAttributeMeta } from 'vault/utils/field-to-attrs';
+import { withModelValidations } from 'vault/decorators/model-validations';
+
+const validations = {
+ name: [
+ { type: 'presence', message: 'Name is required.' },
+ {
+ type: 'containsWhiteSpace',
+ message: 'Name cannot contain whitespace.',
+ },
+ ],
+};
+
+@withModelValidations(validations)
+export default class OidcKeyModel extends Model {
+ @attr('string', { editDisabled: true }) name;
+ @attr('string', {
+ defaultValue: 'RS256',
+ possibleValues: ['RS256', 'RS384', 'RS512', 'ES256', 'ES384', 'ES512', 'EdDSA'],
+ })
+ algorithm;
+
+ @attr({ editType: 'ttl', defaultValue: '24h' }) rotationPeriod;
+ @attr({ label: 'Verification TTL', editType: 'ttl', defaultValue: '24h' }) verificationTtl;
+ @attr('array', { label: 'Allowed applications' }) allowedClientIds; // no editType because does not use form-field component
+
+ // TODO refactor when field-to-attrs is refactored as decorator
+ _attributeMeta = null; // cache initial result of expandAttributeMeta in getter and return
+ get formFields() {
+ if (!this._attributeMeta) {
+ this._attributeMeta = expandAttributeMeta(this, [
+ 'name',
+ 'algorithm',
+ 'rotationPeriod',
+ 'verificationTtl',
+ ]);
+ }
+ return this._attributeMeta;
+ }
+
+ @lazyCapabilities(apiPath`identity/oidc/key/${'name'}`, 'name') keyPath;
+ @lazyCapabilities(apiPath`identity/oidc/key/${'name'}/rotate`, 'name') rotatePath;
+ @lazyCapabilities(apiPath`identity/oidc/key`) keysPath;
+ get canCreate() {
+ return this.keyPath.get('canCreate');
+ }
+ get canRead() {
+ return this.keyPath.get('canRead');
+ }
+ get canEdit() {
+ return this.keyPath.get('canUpdate');
+ }
+ get canRotate() {
+ return this.rotatePath.get('canUpdate');
+ }
+ get canDelete() {
+ return this.keyPath.get('canDelete');
+ }
+ get canList() {
+ return this.keysPath.get('canList');
+ }
+}
diff --git a/ui/app/models/oidc/provider.js b/ui/app/models/oidc/provider.js
new file mode 100644
index 000000000..f87efa900
--- /dev/null
+++ b/ui/app/models/oidc/provider.js
@@ -0,0 +1,74 @@
+import Model, { attr } from '@ember-data/model';
+import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities';
+import { expandAttributeMeta } from 'vault/utils/field-to-attrs';
+import { withModelValidations } from 'vault/decorators/model-validations';
+
+const validations = {
+ name: [
+ { type: 'presence', message: 'Name is required.' },
+ {
+ type: 'containsWhiteSpace',
+ message: 'Name cannot contain whitespace.',
+ },
+ ],
+};
+
+@withModelValidations(validations)
+export default class OidcProviderModel extends Model {
+ @attr('string', { editDisabled: true }) name;
+ @attr('string', {
+ subText:
+ 'The scheme, host, and optional port for your issuer. This will be used to build the URL that validates ID tokens.',
+ placeholderText: 'e.g. https://example.com:8200',
+ docLink: '/api-docs/secret/identity/oidc-provider#create-or-update-a-provider',
+ helpText: `Optional. This defaults to a URL with Vault's api_addr`,
+ })
+ issuer;
+
+ @attr('array', {
+ label: 'Supported scopes',
+ subText: 'Scopes define information about a user and the OIDC service. Optional.',
+ editType: 'searchSelect',
+ models: ['oidc/scope'],
+ fallbackComponent: 'string-list',
+ onlyAllowExisting: true,
+ })
+ scopesSupported;
+
+ @attr('array', { label: 'Allowed applications' }) allowedClientIds; // no editType because does not use form-field component
+
+ // TODO refactor when field-to-attrs is refactored as decorator
+ _attributeMeta = null; // cache initial result of expandAttributeMeta in getter and return
+ get formFields() {
+ if (!this._attributeMeta) {
+ this._attributeMeta = expandAttributeMeta(this, ['name', 'issuer', 'scopesSupported']);
+ }
+ return this._attributeMeta;
+ }
+ @lazyCapabilities(apiPath`identity/oidc/provider/${'name'}`, 'name') providerPath;
+ @lazyCapabilities(apiPath`identity/oidc/provider`) providersPath;
+ get canCreate() {
+ return this.providerPath.get('canCreate');
+ }
+ get canRead() {
+ return this.providerPath.get('canRead');
+ }
+ get canEdit() {
+ return this.providerPath.get('canUpdate');
+ }
+ get canDelete() {
+ return this.providerPath.get('canDelete');
+ }
+ get canList() {
+ return this.providersPath.get('canList');
+ }
+
+ @lazyCapabilities(apiPath`identity/oidc/client`) clientsPath;
+ get canListClients() {
+ return this.clientsPath.get('canList');
+ }
+ @lazyCapabilities(apiPath`identity/oidc/scope`) scopesPath;
+ get canListScopes() {
+ return this.scopesPath.get('canList');
+ }
+}
diff --git a/ui/app/models/oidc/scope.js b/ui/app/models/oidc/scope.js
new file mode 100644
index 000000000..aed338e68
--- /dev/null
+++ b/ui/app/models/oidc/scope.js
@@ -0,0 +1,42 @@
+import Model, { attr } from '@ember-data/model';
+import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities';
+import { expandAttributeMeta } from 'vault/utils/field-to-attrs';
+import { withModelValidations } from 'vault/decorators/model-validations';
+
+const validations = {
+ name: [{ type: 'presence', message: 'Name is required.' }],
+};
+
+@withModelValidations(validations)
+export default class OidcScopeModel extends Model {
+ @attr('string', { editDisabled: true }) name;
+ @attr('string', { editType: 'textarea' }) description;
+ @attr('string', { label: 'JSON Template', editType: 'json', mode: 'ruby' }) template;
+
+ // TODO refactor when field-to-attrs is refactored as decorator
+ _attributeMeta = null; // cache initial result of expandAttributeMeta in getter and return
+ get formFields() {
+ if (!this._attributeMeta) {
+ this._attributeMeta = expandAttributeMeta(this, ['name', 'description', 'template']);
+ }
+ return this._attributeMeta;
+ }
+
+ @lazyCapabilities(apiPath`identity/oidc/scope/${'name'}`, 'name') scopePath;
+ @lazyCapabilities(apiPath`identity/oidc/scope`) scopesPath;
+ get canCreate() {
+ return this.scopePath.get('canCreate');
+ }
+ get canRead() {
+ return this.scopePath.get('canRead');
+ }
+ get canEdit() {
+ return this.scopePath.get('canUpdate');
+ }
+ get canDelete() {
+ return this.scopePath.get('canDelete');
+ }
+ get canList() {
+ return this.scopesPath.get('canList');
+ }
+}
diff --git a/ui/app/router.js b/ui/app/router.js
index e59a5c6d7..458869121 100644
--- a/ui/app/router.js
+++ b/ui/app/router.js
@@ -109,6 +109,46 @@ Router.map(function () {
this.route('index', { path: '/' });
this.route('create');
});
+ this.route('oidc', function () {
+ this.route('clients', function () {
+ this.route('create');
+ this.route('client', { path: '/:name' }, function () {
+ this.route('details');
+ this.route('providers');
+ this.route('edit');
+ });
+ });
+ this.route('keys', function () {
+ this.route('create');
+ this.route('key', { path: '/:name' }, function () {
+ this.route('details');
+ this.route('clients');
+ this.route('edit');
+ });
+ });
+ this.route('assignments', function () {
+ this.route('create');
+ this.route('assignment', { path: '/:name' }, function () {
+ this.route('details');
+ this.route('edit');
+ });
+ });
+ this.route('providers', function () {
+ this.route('create');
+ this.route('provider', { path: '/:name' }, function () {
+ this.route('details');
+ this.route('clients');
+ this.route('edit');
+ });
+ });
+ this.route('scopes', function () {
+ this.route('create');
+ this.route('scope', { path: '/:name' }, function () {
+ this.route('details');
+ this.route('edit');
+ });
+ });
+ });
});
this.route('secrets', function () {
this.route('backends', { path: '/' });
diff --git a/ui/app/routes/vault/cluster/access/oidc.js b/ui/app/routes/vault/cluster/access/oidc.js
new file mode 100644
index 000000000..18903d49e
--- /dev/null
+++ b/ui/app/routes/vault/cluster/access/oidc.js
@@ -0,0 +1,3 @@
+import Route from '@ember/routing/route';
+
+export default class OidcConfigureRoute extends Route {}
diff --git a/ui/app/routes/vault/cluster/access/oidc/assignments/assignment.js b/ui/app/routes/vault/cluster/access/oidc/assignments/assignment.js
new file mode 100644
index 000000000..54011c1df
--- /dev/null
+++ b/ui/app/routes/vault/cluster/access/oidc/assignments/assignment.js
@@ -0,0 +1,7 @@
+import Route from '@ember/routing/route';
+
+export default class OidcAssignmentRoute extends Route {
+ model({ name }) {
+ return this.store.findRecord('oidc/assignment', name);
+ }
+}
diff --git a/ui/app/routes/vault/cluster/access/oidc/assignments/assignment/details.js b/ui/app/routes/vault/cluster/access/oidc/assignments/assignment/details.js
new file mode 100644
index 000000000..bdc58a723
--- /dev/null
+++ b/ui/app/routes/vault/cluster/access/oidc/assignments/assignment/details.js
@@ -0,0 +1,3 @@
+import Route from '@ember/routing/route';
+
+export default class OidcAssignmentDetailsRoute extends Route {}
diff --git a/ui/app/routes/vault/cluster/access/oidc/assignments/assignment/edit.js b/ui/app/routes/vault/cluster/access/oidc/assignments/assignment/edit.js
new file mode 100644
index 000000000..e4dd299a2
--- /dev/null
+++ b/ui/app/routes/vault/cluster/access/oidc/assignments/assignment/edit.js
@@ -0,0 +1,3 @@
+import Route from '@ember/routing/route';
+
+export default class OidcAssignmentEditRoute extends Route {}
diff --git a/ui/app/routes/vault/cluster/access/oidc/assignments/create.js b/ui/app/routes/vault/cluster/access/oidc/assignments/create.js
new file mode 100644
index 000000000..414ae467b
--- /dev/null
+++ b/ui/app/routes/vault/cluster/access/oidc/assignments/create.js
@@ -0,0 +1,7 @@
+import Route from '@ember/routing/route';
+
+export default class OidcAssignmentsCreateRoute extends Route {
+ model() {
+ return this.store.createRecord('oidc/assignment');
+ }
+}
diff --git a/ui/app/routes/vault/cluster/access/oidc/assignments/index.js b/ui/app/routes/vault/cluster/access/oidc/assignments/index.js
new file mode 100644
index 000000000..3956673c5
--- /dev/null
+++ b/ui/app/routes/vault/cluster/access/oidc/assignments/index.js
@@ -0,0 +1,13 @@
+import Route from '@ember/routing/route';
+
+export default class OidcAssignmentsRoute extends Route {
+ model() {
+ return this.store.query('oidc/assignment', {}).catch((err) => {
+ if (err.httpStatus === 404) {
+ return [];
+ } else {
+ throw err;
+ }
+ });
+ }
+}
diff --git a/ui/app/routes/vault/cluster/access/oidc/clients/client.js b/ui/app/routes/vault/cluster/access/oidc/clients/client.js
new file mode 100644
index 000000000..f2363a6e3
--- /dev/null
+++ b/ui/app/routes/vault/cluster/access/oidc/clients/client.js
@@ -0,0 +1,7 @@
+import Route from '@ember/routing/route';
+
+export default class OidcClientRoute extends Route {
+ model({ name }) {
+ return this.store.findRecord('oidc/client', name);
+ }
+}
diff --git a/ui/app/routes/vault/cluster/access/oidc/clients/client/details.js b/ui/app/routes/vault/cluster/access/oidc/clients/client/details.js
new file mode 100644
index 000000000..db38b6058
--- /dev/null
+++ b/ui/app/routes/vault/cluster/access/oidc/clients/client/details.js
@@ -0,0 +1,2 @@
+import Route from '@ember/routing/route';
+export default class OidcClientDetailsRoute extends Route {}
diff --git a/ui/app/routes/vault/cluster/access/oidc/clients/client/edit.js b/ui/app/routes/vault/cluster/access/oidc/clients/client/edit.js
new file mode 100644
index 000000000..fd4e7a5bb
--- /dev/null
+++ b/ui/app/routes/vault/cluster/access/oidc/clients/client/edit.js
@@ -0,0 +1,3 @@
+import Route from '@ember/routing/route';
+
+export default class OidcClientEditRoute extends Route {}
diff --git a/ui/app/routes/vault/cluster/access/oidc/clients/client/providers.js b/ui/app/routes/vault/cluster/access/oidc/clients/client/providers.js
new file mode 100644
index 000000000..653a50fab
--- /dev/null
+++ b/ui/app/routes/vault/cluster/access/oidc/clients/client/providers.js
@@ -0,0 +1,18 @@
+import Route from '@ember/routing/route';
+
+export default class OidcClientProvidersRoute extends Route {
+ model() {
+ const model = this.modelFor('vault.cluster.access.oidc.clients.client');
+ return this.store
+ .query('oidc/provider', {
+ allowed_client_id: model.clientId,
+ })
+ .catch((err) => {
+ if (err.httpStatus === 404) {
+ return [];
+ } else {
+ throw err;
+ }
+ });
+ }
+}
diff --git a/ui/app/routes/vault/cluster/access/oidc/clients/create.js b/ui/app/routes/vault/cluster/access/oidc/clients/create.js
new file mode 100644
index 000000000..ba8674471
--- /dev/null
+++ b/ui/app/routes/vault/cluster/access/oidc/clients/create.js
@@ -0,0 +1,7 @@
+import Route from '@ember/routing/route';
+
+export default class OidcClientsCreateRoute extends Route {
+ model() {
+ return this.store.createRecord('oidc/client');
+ }
+}
diff --git a/ui/app/routes/vault/cluster/access/oidc/clients/index.js b/ui/app/routes/vault/cluster/access/oidc/clients/index.js
new file mode 100644
index 000000000..08ec2bef2
--- /dev/null
+++ b/ui/app/routes/vault/cluster/access/oidc/clients/index.js
@@ -0,0 +1,21 @@
+import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
+export default class OidcClientsRoute extends Route {
+ @service router;
+
+ model() {
+ return this.store.query('oidc/client', {}).catch((err) => {
+ if (err.httpStatus === 404) {
+ return [];
+ } else {
+ throw err;
+ }
+ });
+ }
+
+ afterModel(model) {
+ if (model.length === 0) {
+ this.router.transitionTo('vault.cluster.access.oidc');
+ }
+ }
+}
diff --git a/ui/app/routes/vault/cluster/access/oidc/index.js b/ui/app/routes/vault/cluster/access/oidc/index.js
new file mode 100644
index 000000000..f7262a7e9
--- /dev/null
+++ b/ui/app/routes/vault/cluster/access/oidc/index.js
@@ -0,0 +1,18 @@
+import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
+
+export default class OidcConfigureRoute extends Route {
+ @service router;
+
+ beforeModel() {
+ return this.store
+ .query('oidc/client', {})
+ .then(() => {
+ // transition to client list view if clients have been created
+ this.router.transitionTo('vault.cluster.access.oidc.clients');
+ })
+ .catch(() => {
+ // adapter throws error for 404 - swallow and remain on index route to show call to action
+ });
+ }
+}
diff --git a/ui/app/routes/vault/cluster/access/oidc/keys/create.js b/ui/app/routes/vault/cluster/access/oidc/keys/create.js
new file mode 100644
index 000000000..424e2806c
--- /dev/null
+++ b/ui/app/routes/vault/cluster/access/oidc/keys/create.js
@@ -0,0 +1,7 @@
+import Route from '@ember/routing/route';
+
+export default class OidcKeysCreateRoute extends Route {
+ model() {
+ return this.store.createRecord('oidc/key');
+ }
+}
diff --git a/ui/app/routes/vault/cluster/access/oidc/keys/index.js b/ui/app/routes/vault/cluster/access/oidc/keys/index.js
new file mode 100644
index 000000000..5e9b48be9
--- /dev/null
+++ b/ui/app/routes/vault/cluster/access/oidc/keys/index.js
@@ -0,0 +1,12 @@
+import Route from '@ember/routing/route';
+export default class OidcKeysRoute extends Route {
+ model() {
+ return this.store.query('oidc/key', {}).catch((err) => {
+ if (err.httpStatus === 404) {
+ return [];
+ } else {
+ throw err;
+ }
+ });
+ }
+}
diff --git a/ui/app/routes/vault/cluster/access/oidc/keys/key.js b/ui/app/routes/vault/cluster/access/oidc/keys/key.js
new file mode 100644
index 000000000..d537e62c3
--- /dev/null
+++ b/ui/app/routes/vault/cluster/access/oidc/keys/key.js
@@ -0,0 +1,7 @@
+import Route from '@ember/routing/route';
+
+export default class OidcKeyRoute extends Route {
+ model({ name }) {
+ return this.store.findRecord('oidc/key', name);
+ }
+}
diff --git a/ui/app/routes/vault/cluster/access/oidc/keys/key/clients.js b/ui/app/routes/vault/cluster/access/oidc/keys/key/clients.js
new file mode 100644
index 000000000..a96aba0ae
--- /dev/null
+++ b/ui/app/routes/vault/cluster/access/oidc/keys/key/clients.js
@@ -0,0 +1,8 @@
+import Route from '@ember/routing/route';
+
+export default class OidcKeyClientsRoute extends Route {
+ async model() {
+ const { allowedClientIds } = this.modelFor('vault.cluster.access.oidc.keys.key');
+ return await this.store.query('oidc/client', { paramKey: 'client_id', filterFor: allowedClientIds });
+ }
+}
diff --git a/ui/app/routes/vault/cluster/access/oidc/keys/key/details.js b/ui/app/routes/vault/cluster/access/oidc/keys/key/details.js
new file mode 100644
index 000000000..df05a168c
--- /dev/null
+++ b/ui/app/routes/vault/cluster/access/oidc/keys/key/details.js
@@ -0,0 +1,3 @@
+import Route from '@ember/routing/route';
+
+export default class OidcKeyDetailsRoute extends Route {}
diff --git a/ui/app/routes/vault/cluster/access/oidc/keys/key/edit.js b/ui/app/routes/vault/cluster/access/oidc/keys/key/edit.js
new file mode 100644
index 000000000..c86f197c3
--- /dev/null
+++ b/ui/app/routes/vault/cluster/access/oidc/keys/key/edit.js
@@ -0,0 +1,3 @@
+import Route from '@ember/routing/route';
+
+export default class OidcKeyEditRoute extends Route {}
diff --git a/ui/app/routes/vault/cluster/access/oidc/providers/create.js b/ui/app/routes/vault/cluster/access/oidc/providers/create.js
new file mode 100644
index 000000000..3a6b9667b
--- /dev/null
+++ b/ui/app/routes/vault/cluster/access/oidc/providers/create.js
@@ -0,0 +1,7 @@
+import Route from '@ember/routing/route';
+
+export default class OidcProvidersCreateRoute extends Route {
+ model() {
+ return this.store.createRecord('oidc/provider');
+ }
+}
diff --git a/ui/app/routes/vault/cluster/access/oidc/providers/index.js b/ui/app/routes/vault/cluster/access/oidc/providers/index.js
new file mode 100644
index 000000000..82250f19d
--- /dev/null
+++ b/ui/app/routes/vault/cluster/access/oidc/providers/index.js
@@ -0,0 +1,13 @@
+import Route from '@ember/routing/route';
+
+export default class OidcProvidersRoute extends Route {
+ model() {
+ return this.store.query('oidc/provider', {}).catch((err) => {
+ if (err.httpStatus === 404) {
+ return [];
+ } else {
+ throw err;
+ }
+ });
+ }
+}
diff --git a/ui/app/routes/vault/cluster/access/oidc/providers/provider.js b/ui/app/routes/vault/cluster/access/oidc/providers/provider.js
new file mode 100644
index 000000000..66a8fadaa
--- /dev/null
+++ b/ui/app/routes/vault/cluster/access/oidc/providers/provider.js
@@ -0,0 +1,7 @@
+import Route from '@ember/routing/route';
+
+export default class OidcProviderRoute extends Route {
+ model({ name }) {
+ return this.store.findRecord('oidc/provider', name);
+ }
+}
diff --git a/ui/app/routes/vault/cluster/access/oidc/providers/provider/clients.js b/ui/app/routes/vault/cluster/access/oidc/providers/provider/clients.js
new file mode 100644
index 000000000..586a73c13
--- /dev/null
+++ b/ui/app/routes/vault/cluster/access/oidc/providers/provider/clients.js
@@ -0,0 +1,8 @@
+import Route from '@ember/routing/route';
+
+export default class OidcProviderClientsRoute extends Route {
+ async model() {
+ const { allowedClientIds } = this.modelFor('vault.cluster.access.oidc.providers.provider');
+ return await this.store.query('oidc/client', { paramKey: 'client_id', filterFor: allowedClientIds });
+ }
+}
diff --git a/ui/app/routes/vault/cluster/access/oidc/providers/provider/details.js b/ui/app/routes/vault/cluster/access/oidc/providers/provider/details.js
new file mode 100644
index 000000000..af4d1077d
--- /dev/null
+++ b/ui/app/routes/vault/cluster/access/oidc/providers/provider/details.js
@@ -0,0 +1,3 @@
+import Route from '@ember/routing/route';
+
+export default class OidcProviderDetailsRoute extends Route {}
diff --git a/ui/app/routes/vault/cluster/access/oidc/providers/provider/edit.js b/ui/app/routes/vault/cluster/access/oidc/providers/provider/edit.js
new file mode 100644
index 000000000..365b2328e
--- /dev/null
+++ b/ui/app/routes/vault/cluster/access/oidc/providers/provider/edit.js
@@ -0,0 +1,3 @@
+import Route from '@ember/routing/route';
+
+export default class OidcProviderEditRoute extends Route {}
diff --git a/ui/app/routes/vault/cluster/access/oidc/scopes/create.js b/ui/app/routes/vault/cluster/access/oidc/scopes/create.js
new file mode 100644
index 000000000..cd1862279
--- /dev/null
+++ b/ui/app/routes/vault/cluster/access/oidc/scopes/create.js
@@ -0,0 +1,7 @@
+import Route from '@ember/routing/route';
+
+export default class OidcScopesCreateRoute extends Route {
+ model() {
+ return this.store.createRecord('oidc/scope');
+ }
+}
diff --git a/ui/app/routes/vault/cluster/access/oidc/scopes/index.js b/ui/app/routes/vault/cluster/access/oidc/scopes/index.js
new file mode 100644
index 000000000..e8108980a
--- /dev/null
+++ b/ui/app/routes/vault/cluster/access/oidc/scopes/index.js
@@ -0,0 +1,13 @@
+import Route from '@ember/routing/route';
+
+export default class OidcScopesRoute extends Route {
+ model() {
+ return this.store.query('oidc/scope', {}).catch((err) => {
+ if (err.httpStatus === 404) {
+ return [];
+ } else {
+ throw err;
+ }
+ });
+ }
+}
diff --git a/ui/app/routes/vault/cluster/access/oidc/scopes/scope.js b/ui/app/routes/vault/cluster/access/oidc/scopes/scope.js
new file mode 100644
index 000000000..7a4c5c08d
--- /dev/null
+++ b/ui/app/routes/vault/cluster/access/oidc/scopes/scope.js
@@ -0,0 +1,7 @@
+import Route from '@ember/routing/route';
+
+export default class OidcScopeRoute extends Route {
+ model({ name }) {
+ return this.store.findRecord('oidc/scope', name);
+ }
+}
diff --git a/ui/app/routes/vault/cluster/access/oidc/scopes/scope/details.js b/ui/app/routes/vault/cluster/access/oidc/scopes/scope/details.js
new file mode 100644
index 000000000..6047afcfc
--- /dev/null
+++ b/ui/app/routes/vault/cluster/access/oidc/scopes/scope/details.js
@@ -0,0 +1,3 @@
+import Route from '@ember/routing/route';
+
+export default class OidcScopeDetailsRoute extends Route {}
diff --git a/ui/app/routes/vault/cluster/access/oidc/scopes/scope/edit.js b/ui/app/routes/vault/cluster/access/oidc/scopes/scope/edit.js
new file mode 100644
index 000000000..fc8b61fc0
--- /dev/null
+++ b/ui/app/routes/vault/cluster/access/oidc/scopes/scope/edit.js
@@ -0,0 +1,3 @@
+import Route from '@ember/routing/route';
+
+export default class OidcScopeEditRoute extends Route {}
diff --git a/ui/app/serializers/oidc/assignment.js b/ui/app/serializers/oidc/assignment.js
new file mode 100644
index 000000000..1a7693d6c
--- /dev/null
+++ b/ui/app/serializers/oidc/assignment.js
@@ -0,0 +1,5 @@
+import ApplicationSerializer from '../application';
+
+export default class OidcAssignmentSerializer extends ApplicationSerializer {
+ primaryKey = 'name';
+}
diff --git a/ui/app/serializers/oidc/client.js b/ui/app/serializers/oidc/client.js
new file mode 100644
index 000000000..784d56cc8
--- /dev/null
+++ b/ui/app/serializers/oidc/client.js
@@ -0,0 +1,17 @@
+import ApplicationSerializer from '../application';
+
+export default class OidcClientSerializer extends ApplicationSerializer {
+ primaryKey = 'name';
+
+ // rehydrate each client model so all model attributes are accessible from the LIST response
+ normalizeItems(payload) {
+ if (payload.data) {
+ if (payload.data?.keys && Array.isArray(payload.data.keys)) {
+ return payload.data.keys.map((key) => ({ name: key, ...payload.data.key_info[key] }));
+ }
+ Object.assign(payload, payload.data);
+ delete payload.data;
+ }
+ return payload;
+ }
+}
diff --git a/ui/app/serializers/oidc/key.js b/ui/app/serializers/oidc/key.js
new file mode 100644
index 000000000..bf2890f40
--- /dev/null
+++ b/ui/app/serializers/oidc/key.js
@@ -0,0 +1,5 @@
+import ApplicationSerializer from '../application';
+
+export default class OidcKeySerializer extends ApplicationSerializer {
+ primaryKey = 'name';
+}
diff --git a/ui/app/serializers/oidc/provider.js b/ui/app/serializers/oidc/provider.js
new file mode 100644
index 000000000..da0b35e29
--- /dev/null
+++ b/ui/app/serializers/oidc/provider.js
@@ -0,0 +1,17 @@
+import ApplicationSerializer from '../application';
+
+export default class OidcProviderSerializer extends ApplicationSerializer {
+ primaryKey = 'name';
+
+ // need to normalize to get issuer metadata for provider's list view
+ normalizeItems(payload) {
+ if (payload.data) {
+ if (payload.data?.keys && Array.isArray(payload.data.keys)) {
+ return payload.data.keys.map((key) => ({ name: key, ...payload.data.key_info[key] }));
+ }
+ Object.assign(payload, payload.data);
+ delete payload.data;
+ }
+ return payload;
+ }
+}
diff --git a/ui/app/serializers/oidc/scope.js b/ui/app/serializers/oidc/scope.js
new file mode 100644
index 000000000..17c7c0202
--- /dev/null
+++ b/ui/app/serializers/oidc/scope.js
@@ -0,0 +1,5 @@
+import ApplicationSerializer from '../application';
+
+export default class OidcScopeSerializer extends ApplicationSerializer {
+ primaryKey = 'name';
+}
diff --git a/ui/app/services/permissions.js b/ui/app/services/permissions.js
index 6364ab445..a8d50b953 100644
--- a/ui/app/services/permissions.js
+++ b/ui/app/services/permissions.js
@@ -5,6 +5,7 @@ const API_PATHS = {
access: {
methods: 'sys/auth',
mfa: 'identity/mfa/method',
+ oidc: 'identity/oidc/client',
entities: 'identity/entity/id',
groups: 'identity/group/id',
leases: 'sys/leases/lookup',
@@ -44,6 +45,7 @@ const API_PATHS_TO_ROUTE_PARAMS = {
'sys/namespaces': { route: 'vault.cluster.access.namespaces', models: [] },
'sys/control-group/': { route: 'vault.cluster.access.control-groups', models: [] },
'identity/mfa/method': { route: 'vault.cluster.access.mfa', models: [] },
+ 'identity/oidc/client': { route: 'vault.cluster.access.oidc', models: [] },
};
/*
diff --git a/ui/app/styles/components/list-item-row.scss b/ui/app/styles/components/list-item-row.scss
index 75cad84fb..9bdc702f3 100644
--- a/ui/app/styles/components/list-item-row.scss
+++ b/ui/app/styles/components/list-item-row.scss
@@ -22,6 +22,10 @@
margin-left: auto;
margin-right: auto;
}
+
+ &.is-disabled {
+ opacity: 0.5;
+ }
}
a.list-item-row,
diff --git a/ui/app/styles/components/radio-card.scss b/ui/app/styles/components/radio-card.scss
index f65e21e6f..e98020eb6 100644
--- a/ui/app/styles/components/radio-card.scss
+++ b/ui/app/styles/components/radio-card.scss
@@ -3,16 +3,15 @@
margin-bottom: $spacing-xs;
}
.radio-card {
- width: 19rem;
box-shadow: $box-shadow-low;
- display: flex;
+ flex: 1 1 25%;
flex-direction: column;
justify-content: space-between;
margin: $spacing-xs $spacing-m;
border: $base-border;
border-radius: $radius;
transition: all ease-in-out $speed;
-
+ max-width: 60%;
input[type='radio'] {
position: absolute;
z-index: 1;
diff --git a/ui/app/styles/core/buttons.scss b/ui/app/styles/core/buttons.scss
index 031ee33e9..9ae0a2e84 100644
--- a/ui/app/styles/core/buttons.scss
+++ b/ui/app/styles/core/buttons.scss
@@ -272,3 +272,13 @@ a.button.disabled {
border: none;
cursor: pointer;
}
+.text-button {
+ padding: unset;
+ border: none;
+ background-color: inherit;
+ color: inherit;
+ font-size: inherit;
+ font-weight: inherit;
+ cursor: pointer;
+ color: $link;
+}
diff --git a/ui/app/styles/core/forms.scss b/ui/app/styles/core/forms.scss
index 55d4350df..ef41a3edd 100644
--- a/ui/app/styles/core/forms.scss
+++ b/ui/app/styles/core/forms.scss
@@ -331,6 +331,11 @@ select.has-error-border {
border: 1px solid $red-500;
}
+.dropdown-has-error-border > div.ember-basic-dropdown-trigger {
+ border: 1px solid $red-500;
+}
+
+
.autocomplete-input {
background: $white !important;
border: 1px solid $grey-light;
diff --git a/ui/app/styles/core/helpers.scss b/ui/app/styles/core/helpers.scss
index 72c0423eb..88b741088 100644
--- a/ui/app/styles/core/helpers.scss
+++ b/ui/app/styles/core/helpers.scss
@@ -174,6 +174,9 @@
.has-top-padding-l {
padding-top: $spacing-l;
}
+.has-top-padding-xxl {
+ padding-top: $spacing-xxl;
+}
.has-bottom-margin-xs {
margin-bottom: $spacing-xs;
}
diff --git a/ui/app/templates/components/alphabet-edit.hbs b/ui/app/templates/components/alphabet-edit.hbs
index 8d58eff1b..0e764c7ac 100644
--- a/ui/app/templates/components/alphabet-edit.hbs
+++ b/ui/app/templates/components/alphabet-edit.hbs
@@ -116,7 +116,6 @@
@value={{get this.model attr.name}}
@type={{attr.type}}
@isLink={{eq attr.name "transformations"}}
- @viewAll="transformations"
/>
{{else}}
{{attr.options.helpText}}
{{#if attr.options.docLink}}
-
+
See our documentation
-
+
for help.
{{/if}}
@@ -43,9 +43,9 @@
{{attr.options.subText}}
{{#if attr.options.docLink}}
-
+
See our documentation
-
+
for help.
{{/if}}
diff --git a/ui/app/templates/components/mfa/mfa-login-enforcement-form.hbs b/ui/app/templates/components/mfa/mfa-login-enforcement-form.hbs
index cf9f9554b..7b8c51a0c 100644
--- a/ui/app/templates/components/mfa/mfa-login-enforcement-form.hbs
+++ b/ui/app/templates/components/mfa/mfa-login-enforcement-form.hbs
@@ -10,7 +10,7 @@
spellcheck="false"
value={{@model.name}}
disabled={{not @model.isNew}}
- class="input field"
+ class="input field {{if this.errors.name.errors 'has-error-border'}}"
data-test-mlef-input="name"
{{on "input" (pipe (pick "target.value") (fn (mut @model.name)))}}
/>
diff --git a/ui/app/templates/components/oidc/assignment-form.hbs b/ui/app/templates/components/oidc/assignment-form.hbs
new file mode 100644
index 000000000..5ec113187
--- /dev/null
+++ b/ui/app/templates/components/oidc/assignment-form.hbs
@@ -0,0 +1,95 @@
+{{#unless @isInline}}
+
+
+
+
+
+ /
+ {{#if @model.isNew}}
+
+ Assignments
+
+ {{else}}
+ {{! You're editing in this view }}
+
+ Details
+
+ {{/if}}
+
+
+
+
+
+
+ {{if @model.isNew "Create" "Edit"}}
+ assignment
+
+
+
+{{/unless}}
+
\ No newline at end of file
diff --git a/ui/app/templates/components/oidc/client-form.hbs b/ui/app/templates/components/oidc/client-form.hbs
new file mode 100644
index 000000000..893ff199a
--- /dev/null
+++ b/ui/app/templates/components/oidc/client-form.hbs
@@ -0,0 +1,99 @@
+
+
+
+
+
+ /
+ {{#if @model.isNew}}
+
+ Applications
+
+ {{else}}
+
+ Details
+
+ {{/if}}
+
+
+
+
+
+
+ {{if @model.isNew "Create" "Edit"}}
+ application
+
+
+
+
\ No newline at end of file
diff --git a/ui/app/templates/components/oidc/client-list.hbs b/ui/app/templates/components/oidc/client-list.hbs
new file mode 100644
index 000000000..c60f07514
--- /dev/null
+++ b/ui/app/templates/components/oidc/client-list.hbs
@@ -0,0 +1,52 @@
+{{#each @model as |client|}}
+
+
+
+
+
+
+ {{client.name}}
+
+
+ Client ID:
+ {{client.clientId}}
+
+
+
+
+
+
+{{/each}}
\ No newline at end of file
diff --git a/ui/app/templates/components/oidc/key-form.hbs b/ui/app/templates/components/oidc/key-form.hbs
new file mode 100644
index 000000000..2df87e951
--- /dev/null
+++ b/ui/app/templates/components/oidc/key-form.hbs
@@ -0,0 +1,100 @@
+
+
+
+
+
+ /
+ {{#if @model.isNew}}
+
+ Keys
+
+ {{else}}
+
+ Details
+
+ {{/if}}
+
+
+
+
+
+
+ {{if @model.isNew "Create" "Edit"}}
+ key
+
+
+
+
+
\ No newline at end of file
diff --git a/ui/app/templates/components/oidc/provider-form.hbs b/ui/app/templates/components/oidc/provider-form.hbs
new file mode 100644
index 000000000..4a8331dc3
--- /dev/null
+++ b/ui/app/templates/components/oidc/provider-form.hbs
@@ -0,0 +1,127 @@
+
+
+
+
+
+ /
+ {{#if @model.isNew}}
+
+ Providers
+
+ {{else}}
+
+ Details
+
+ {{/if}}
+
+
+
+
+
+
+ {{if @model.isNew "Create" "Edit"}}
+ provider
+
+
+
+
+
\ No newline at end of file
diff --git a/ui/app/templates/components/oidc/provider-list.hbs b/ui/app/templates/components/oidc/provider-list.hbs
new file mode 100644
index 000000000..9f20d8972
--- /dev/null
+++ b/ui/app/templates/components/oidc/provider-list.hbs
@@ -0,0 +1,52 @@
+{{#each @model as |provider|}}
+
+
+
+
+
+
+ {{provider.name}}
+
+
+ Issuer:
+ {{provider.issuer}}
+
+
+
+
+
+
+{{/each}}
\ No newline at end of file
diff --git a/ui/app/templates/components/oidc/scope-form.hbs b/ui/app/templates/components/oidc/scope-form.hbs
new file mode 100644
index 000000000..b5af5588b
--- /dev/null
+++ b/ui/app/templates/components/oidc/scope-form.hbs
@@ -0,0 +1,113 @@
+
+
+
+
+
+ /
+ {{#if @model.isNew}}
+
+ Scopes
+
+ {{else}}
+ {{! You're editing in this view }}
+
+ Details
+
+ {{/if}}
+
+
+
+
+
+
+ {{if @model.isNew "Create" "Edit"}}
+ scope
+
+
+
+
+
+
+
+
+
+
+ Example of a JSON template for scopes:
+
+
+
+
+
+ {{! code-mirror modifier does not render value initially in wormhole until focus event fires }}
+ {{! wait until the Modal is rendered and then show the JsonEditor }}
+ {{#if this.showTemplateModal}}
+
+ {{/if}}
+
+ The full list of template parameters can be found
+
+ here.
+
+
+
+
+
+ Close
+
+
+
\ No newline at end of file
diff --git a/ui/app/templates/components/radio-card.hbs b/ui/app/templates/components/radio-card.hbs
index 2a4669686..df29246c3 100644
--- a/ui/app/templates/components/radio-card.hbs
+++ b/ui/app/templates/components/radio-card.hbs
@@ -3,33 +3,44 @@
class="radio-card {{if (eq @value @groupValue) 'is-selected'}} {{if @disabled 'is-disabled'}}"
...attributes
>
- {{#if (has-block)}}
- {{yield}}
- {{else}}
-
-
-
+
+
+ {{#if (has-block)}}
+ {{yield}}
+ {{else}}
+
+
+
+
+
+
+ {{@title}}
+
+
+ {{@description}}
+
+
+
+ {{/if}}
+
+
+
-
-
- {{@title}}
-
-
- {{@description}}
-
-
-
- {{/if}}
-
-
-
-
+
+ {{#if (and @disabled @disabledTooltipMessage)}}
+
+
+ {{@disabledTooltipMessage}}
+
+
+ {{/if}}
+
\ No newline at end of file
diff --git a/ui/app/templates/components/transform-role-edit.hbs b/ui/app/templates/components/transform-role-edit.hbs
index 7d46520c0..b6ae0bd67 100644
--- a/ui/app/templates/components/transform-role-edit.hbs
+++ b/ui/app/templates/components/transform-role-edit.hbs
@@ -99,7 +99,6 @@
@value={{get this.model attr.name}}
@type={{attr.type}}
@isLink={{eq attr.name "transformations"}}
- @viewAll="transformations"
/>
{{else}}
{{else}}
diff --git a/ui/app/templates/vault/cluster/access.hbs b/ui/app/templates/vault/cluster/access.hbs
index 9cd363e32..4f9a8b4f6 100644
--- a/ui/app/templates/vault/cluster/access.hbs
+++ b/ui/app/templates/vault/cluster/access.hbs
@@ -61,6 +61,13 @@
{{/if}}
+ {{#if (has-permission "access" routeParams="oidc")}}
+
+
+ OIDC Provider
+
+
+ {{/if}}
{{outlet}}
diff --git a/ui/app/templates/vault/cluster/access/oidc.hbs b/ui/app/templates/vault/cluster/access/oidc.hbs
new file mode 100644
index 000000000..ff76bc722
--- /dev/null
+++ b/ui/app/templates/vault/cluster/access/oidc.hbs
@@ -0,0 +1,59 @@
+{{#if this.header}}
+
+
+
+ OIDC Provider
+
+
+
+
+
+ Configure Vault to act as an OIDC identity provider, and offer
+ {{"Vault’s"}}
+ various authentication
+ {{#if this.isCta}}
+
+ {{/if}}
+ methods and source of identity to any client applications.
+
+ Learn more
+
+
+ {{#if this.isCta}}
+
+ Create your first app
+
+ {{/if}}
+
+ {{#unless this.isCta}}
+ {{! show tab links in list routes }}
+
+
+
+
+ Applications
+
+
+ Keys
+
+
+ Assignments
+
+
+ Providers
+
+
+ Scopes
+
+
+
+
+ {{/unless}}
+{{/if}}
+
+{{outlet}}
\ No newline at end of file
diff --git a/ui/app/templates/vault/cluster/access/oidc/assignments/assignment/details.hbs b/ui/app/templates/vault/cluster/access/oidc/assignments/assignment/details.hbs
new file mode 100644
index 000000000..0197809b8
--- /dev/null
+++ b/ui/app/templates/vault/cluster/access/oidc/assignments/assignment/details.hbs
@@ -0,0 +1,85 @@
+
+
+
+
+
+ /
+
+ Assignments
+
+
+
+
+
+
+
+ {{@model.name}}
+
+
+
+
+
+
+
+
+ {{#if @model.canDelete}}
+
+ Delete assignment
+
+
+ {{/if}}
+ {{#if @model.canEdit}}
+
+ Edit assignment
+
+ {{/if}}
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/ui/app/templates/vault/cluster/access/oidc/assignments/assignment/edit.hbs b/ui/app/templates/vault/cluster/access/oidc/assignments/assignment/edit.hbs
new file mode 100644
index 000000000..1c302ffa9
--- /dev/null
+++ b/ui/app/templates/vault/cluster/access/oidc/assignments/assignment/edit.hbs
@@ -0,0 +1,5 @@
+
\ No newline at end of file
diff --git a/ui/app/templates/vault/cluster/access/oidc/assignments/create.hbs b/ui/app/templates/vault/cluster/access/oidc/assignments/create.hbs
new file mode 100644
index 000000000..221e6d790
--- /dev/null
+++ b/ui/app/templates/vault/cluster/access/oidc/assignments/create.hbs
@@ -0,0 +1,5 @@
+
\ No newline at end of file
diff --git a/ui/app/templates/vault/cluster/access/oidc/assignments/index.hbs b/ui/app/templates/vault/cluster/access/oidc/assignments/index.hbs
new file mode 100644
index 000000000..2eb79dc07
--- /dev/null
+++ b/ui/app/templates/vault/cluster/access/oidc/assignments/index.hbs
@@ -0,0 +1,71 @@
+
+
+
+ Create assignment
+
+
+
+
+{{#each this.model as |model|}}
+
+
+
+
+
+
+ {{model.name}}
+
+ {{#if (eq model.name "allow_all")}}
+
+ This is a built-in assignment that cannot be modified or deleted.
+
+ Learn more
+
+
+ {{/if}}
+
+
+ {{#if (not-eq model.name "allow_all")}}
+
+ {{/if}}
+
+
+{{/each}}
\ No newline at end of file
diff --git a/ui/app/templates/vault/cluster/access/oidc/clients/client.hbs b/ui/app/templates/vault/cluster/access/oidc/clients/client.hbs
new file mode 100644
index 000000000..6b7f064e3
--- /dev/null
+++ b/ui/app/templates/vault/cluster/access/oidc/clients/client.hbs
@@ -0,0 +1,44 @@
+{{#if this.showHeader}}
+
+
+
+
+
+ /
+
+ Applications
+
+
+
+
+
+
+
+ {{this.model.name}}
+
+
+
+
+
+
+
+
+ Details
+
+
+ Available providers
+
+
+
+
+{{/if}}
+
+{{outlet}}
\ No newline at end of file
diff --git a/ui/app/templates/vault/cluster/access/oidc/clients/client/details.hbs b/ui/app/templates/vault/cluster/access/oidc/clients/client/details.hbs
new file mode 100644
index 000000000..6b4db378f
--- /dev/null
+++ b/ui/app/templates/vault/cluster/access/oidc/clients/client/details.hbs
@@ -0,0 +1,43 @@
+
+
+ {{#if this.model.canDelete}}
+
+ Delete application
+
+
+ {{/if}}
+ {{#if this.model.canEdit}}
+
+ Edit application
+
+ {{/if}}
+
+
+
+
+
+
+
+
+
+
+ {{this.model.key}}
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/ui/app/templates/vault/cluster/access/oidc/clients/client/edit.hbs b/ui/app/templates/vault/cluster/access/oidc/clients/client/edit.hbs
new file mode 100644
index 000000000..657ace87b
--- /dev/null
+++ b/ui/app/templates/vault/cluster/access/oidc/clients/client/edit.hbs
@@ -0,0 +1,5 @@
+
\ No newline at end of file
diff --git a/ui/app/templates/vault/cluster/access/oidc/clients/client/providers.hbs b/ui/app/templates/vault/cluster/access/oidc/clients/client/providers.hbs
new file mode 100644
index 000000000..58fbff753
--- /dev/null
+++ b/ui/app/templates/vault/cluster/access/oidc/clients/client/providers.hbs
@@ -0,0 +1,13 @@
+
+{{#if (gt this.model.length 0)}}
+
+{{else}}
+
+
+ View providers
+
+
+{{/if}}
\ No newline at end of file
diff --git a/ui/app/templates/vault/cluster/access/oidc/clients/create.hbs b/ui/app/templates/vault/cluster/access/oidc/clients/create.hbs
new file mode 100644
index 000000000..b5c962272
--- /dev/null
+++ b/ui/app/templates/vault/cluster/access/oidc/clients/create.hbs
@@ -0,0 +1,5 @@
+
\ No newline at end of file
diff --git a/ui/app/templates/vault/cluster/access/oidc/clients/index.hbs b/ui/app/templates/vault/cluster/access/oidc/clients/index.hbs
new file mode 100644
index 000000000..ccca4b996
--- /dev/null
+++ b/ui/app/templates/vault/cluster/access/oidc/clients/index.hbs
@@ -0,0 +1,9 @@
+
+
+
+ Create application
+
+
+
+
+
\ No newline at end of file
diff --git a/ui/app/templates/vault/cluster/access/oidc/index.hbs b/ui/app/templates/vault/cluster/access/oidc/index.hbs
new file mode 100644
index 000000000..8f6d07d6a
--- /dev/null
+++ b/ui/app/templates/vault/cluster/access/oidc/index.hbs
@@ -0,0 +1,14 @@
+
+
+ Step 1:
+ Create an application, and obtain the client ID, client secret and issuer URL.
+
+
+ Step 2:
+ Set up a new auth method for Vault with the client application.
+
+
+
+
+
+
\ No newline at end of file
diff --git a/ui/app/templates/vault/cluster/access/oidc/keys/create.hbs b/ui/app/templates/vault/cluster/access/oidc/keys/create.hbs
new file mode 100644
index 000000000..763fb7c11
--- /dev/null
+++ b/ui/app/templates/vault/cluster/access/oidc/keys/create.hbs
@@ -0,0 +1,5 @@
+
\ No newline at end of file
diff --git a/ui/app/templates/vault/cluster/access/oidc/keys/index.hbs b/ui/app/templates/vault/cluster/access/oidc/keys/index.hbs
new file mode 100644
index 000000000..514667a19
--- /dev/null
+++ b/ui/app/templates/vault/cluster/access/oidc/keys/index.hbs
@@ -0,0 +1,56 @@
+
+
+
+ Create key
+
+
+
+
+{{#each this.model as |model|}}
+
+
+
+
+
+
+ {{model.name}}
+
+
+
+
+
+
+{{/each}}
\ No newline at end of file
diff --git a/ui/app/templates/vault/cluster/access/oidc/keys/key.hbs b/ui/app/templates/vault/cluster/access/oidc/keys/key.hbs
new file mode 100644
index 000000000..fb219d6da
--- /dev/null
+++ b/ui/app/templates/vault/cluster/access/oidc/keys/key.hbs
@@ -0,0 +1,36 @@
+{{#if this.showHeader}}
+
+
+
+
+
+
+
+
+ {{this.model.name}}
+
+
+
+
+
+
+
+
+ Details
+
+
+ Applications
+
+
+
+
+{{/if}}
+
+{{outlet}}
\ No newline at end of file
diff --git a/ui/app/templates/vault/cluster/access/oidc/keys/key/clients.hbs b/ui/app/templates/vault/cluster/access/oidc/keys/key/clients.hbs
new file mode 100644
index 000000000..af6a49fa0
--- /dev/null
+++ b/ui/app/templates/vault/cluster/access/oidc/keys/key/clients.hbs
@@ -0,0 +1,14 @@
+
+
+{{#if (gt this.model.length 0)}}
+
+{{else}}
+
+
+ Edit key
+
+
+{{/if}}
\ No newline at end of file
diff --git a/ui/app/templates/vault/cluster/access/oidc/keys/key/details.hbs b/ui/app/templates/vault/cluster/access/oidc/keys/key/details.hbs
new file mode 100644
index 000000000..4d208cf32
--- /dev/null
+++ b/ui/app/templates/vault/cluster/access/oidc/keys/key/details.hbs
@@ -0,0 +1,54 @@
+
+
+ {{#if this.model.canDelete}}
+
+
+
+ Delete key
+
+
+ {{#if (eq this.model.name "default")}}
+
+
+ This is a built-in key that cannot be deleted.
+
+
+ {{/if}}
+
+
+ {{/if}}
+ {{#if this.model.canRotate}}
+
+ Rotate key
+
+ {{/if}}
+ {{#if this.model.canEdit}}
+
+ Edit key
+
+ {{/if}}
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/ui/app/templates/vault/cluster/access/oidc/keys/key/edit.hbs b/ui/app/templates/vault/cluster/access/oidc/keys/key/edit.hbs
new file mode 100644
index 000000000..9d2052921
--- /dev/null
+++ b/ui/app/templates/vault/cluster/access/oidc/keys/key/edit.hbs
@@ -0,0 +1,5 @@
+
\ No newline at end of file
diff --git a/ui/app/templates/vault/cluster/access/oidc/providers/create.hbs b/ui/app/templates/vault/cluster/access/oidc/providers/create.hbs
new file mode 100644
index 000000000..08ed12aed
--- /dev/null
+++ b/ui/app/templates/vault/cluster/access/oidc/providers/create.hbs
@@ -0,0 +1,5 @@
+
\ No newline at end of file
diff --git a/ui/app/templates/vault/cluster/access/oidc/providers/index.hbs b/ui/app/templates/vault/cluster/access/oidc/providers/index.hbs
new file mode 100644
index 000000000..7439e03cc
--- /dev/null
+++ b/ui/app/templates/vault/cluster/access/oidc/providers/index.hbs
@@ -0,0 +1,9 @@
+
+
+
+ Create provider
+
+
+
+
+
\ No newline at end of file
diff --git a/ui/app/templates/vault/cluster/access/oidc/providers/provider.hbs b/ui/app/templates/vault/cluster/access/oidc/providers/provider.hbs
new file mode 100644
index 000000000..85875eb25
--- /dev/null
+++ b/ui/app/templates/vault/cluster/access/oidc/providers/provider.hbs
@@ -0,0 +1,44 @@
+{{#if this.showHeader}}
+
+
+
+
+
+ /
+
+ Providers
+
+
+
+
+
+
+
+ {{this.model.name}}
+
+
+
+
+
+
+
+
+ Details
+
+
+ Applications
+
+
+
+
+{{/if}}
+
+{{outlet}}
\ No newline at end of file
diff --git a/ui/app/templates/vault/cluster/access/oidc/providers/provider/clients.hbs b/ui/app/templates/vault/cluster/access/oidc/providers/provider/clients.hbs
new file mode 100644
index 000000000..9e5c5f2c8
--- /dev/null
+++ b/ui/app/templates/vault/cluster/access/oidc/providers/provider/clients.hbs
@@ -0,0 +1,14 @@
+
+
+{{#if (gt this.model.length 0)}}
+
+{{else}}
+
+
+ Edit provider
+
+
+{{/if}}
\ No newline at end of file
diff --git a/ui/app/templates/vault/cluster/access/oidc/providers/provider/details.hbs b/ui/app/templates/vault/cluster/access/oidc/providers/provider/details.hbs
new file mode 100644
index 000000000..7034ab7dc
--- /dev/null
+++ b/ui/app/templates/vault/cluster/access/oidc/providers/provider/details.hbs
@@ -0,0 +1,52 @@
+
+
+ {{#if this.model.canDelete}}
+
+
+
+ Delete provider
+
+
+ {{#if (eq this.model.name "default")}}
+
+
+ This is a built-in provider that cannot be deleted.
+
+
+ {{/if}}
+
+
+ {{/if}}
+ {{#if this.model.canEdit}}
+
+ Edit provider
+
+ {{/if}}
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/ui/app/templates/vault/cluster/access/oidc/providers/provider/edit.hbs b/ui/app/templates/vault/cluster/access/oidc/providers/provider/edit.hbs
new file mode 100644
index 000000000..a20ade9ac
--- /dev/null
+++ b/ui/app/templates/vault/cluster/access/oidc/providers/provider/edit.hbs
@@ -0,0 +1,5 @@
+
\ No newline at end of file
diff --git a/ui/app/templates/vault/cluster/access/oidc/scopes/create.hbs b/ui/app/templates/vault/cluster/access/oidc/scopes/create.hbs
new file mode 100644
index 000000000..d0adb8c53
--- /dev/null
+++ b/ui/app/templates/vault/cluster/access/oidc/scopes/create.hbs
@@ -0,0 +1,5 @@
+
\ No newline at end of file
diff --git a/ui/app/templates/vault/cluster/access/oidc/scopes/index.hbs b/ui/app/templates/vault/cluster/access/oidc/scopes/index.hbs
new file mode 100644
index 000000000..b521c0a61
--- /dev/null
+++ b/ui/app/templates/vault/cluster/access/oidc/scopes/index.hbs
@@ -0,0 +1,73 @@
+
+
+
+ Create scope
+
+
+
+
+{{#if (gt this.model.length 0)}}
+ {{#each this.model as |model|}}
+
+
+
+
+
+
+ {{model.name}}
+
+
+
+
+
+
+ {{/each}}
+{{else}}
+
+
+
Use scope to define identity information about the authenticated user.
+
+ Learn more.
+
+
+
+
+ Create scope
+
+
+
+
+{{/if}}
\ No newline at end of file
diff --git a/ui/app/templates/vault/cluster/access/oidc/scopes/scope/details.hbs b/ui/app/templates/vault/cluster/access/oidc/scopes/scope/details.hbs
new file mode 100644
index 000000000..bd2612051
--- /dev/null
+++ b/ui/app/templates/vault/cluster/access/oidc/scopes/scope/details.hbs
@@ -0,0 +1,65 @@
+
+
+
+
+
+
+
+
+ {{this.model.name}}
+
+
+
+
+
+
+
+
+ {{#if this.model.canDelete}}
+
+ Delete scope
+
+
+ {{/if}}
+ {{#if this.model.canEdit}}
+
+ Edit scope
+
+ {{/if}}
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/ui/app/templates/vault/cluster/access/oidc/scopes/scope/edit.hbs b/ui/app/templates/vault/cluster/access/oidc/scopes/scope/edit.hbs
new file mode 100644
index 000000000..3ba271d32
--- /dev/null
+++ b/ui/app/templates/vault/cluster/access/oidc/scopes/scope/edit.hbs
@@ -0,0 +1,5 @@
+
\ No newline at end of file
diff --git a/ui/lib/core/addon/components/alert-inline.js b/ui/lib/core/addon/components/alert-inline.js
index ec670d849..8c7482b40 100644
--- a/ui/lib/core/addon/components/alert-inline.js
+++ b/ui/lib/core/addon/components/alert-inline.js
@@ -13,12 +13,12 @@ import { messageTypes } from 'core/helpers/message-types';
*
* ```
*
- * @param type=null{String} - The alert type passed to the message-types helper.
- * @param [message=null]{String} - The message to display within the alert.
- * @param [paddingTop=false]{Boolean} - Whether or not to add padding above component.
- * @param [isMarginless=false]{Boolean} - Whether or not to remove margin bottom below component.
- * @param [sizeSmall=false]{Boolean} - Whether or not to display a small font with padding below of alert message.
- * @param [mimicRefresh=false]{Boolean} - If true will display a loading icon when attributes change (e.g. when a form submits and the alert message changes).
+ * @param {string} type=null - The alert type passed to the message-types helper.
+ * @param {string} [message=null] - The message to display within the alert.
+ * @param {boolean} [paddingTop=false] - Whether or not to add padding above component.
+ * @param {boolean} [isMarginless=false] - Whether or not to remove margin bottom below component.
+ * @param {boolean} [sizeSmall=false] - Whether or not to display a small font with padding below of alert message.
+ * @param {boolean} [mimicRefresh=false] - If true will display a loading icon when attributes change (e.g. when a form submits and the alert message changes).
*/
export default class AlertInlineComponent extends Component {
diff --git a/ui/lib/core/addon/components/form-field-groups.hbs b/ui/lib/core/addon/components/form-field-groups.hbs
index c4803be13..3bb2c37ed 100644
--- a/ui/lib/core/addon/components/form-field-groups.hbs
+++ b/ui/lib/core/addon/components/form-field-groups.hbs
@@ -3,8 +3,8 @@
{{#if (or (not @renderGroup) (and @renderGroup (eq group @renderGroup)))}}
{{#if (eq group "default")}}
{{#each fields as |attr|}}
- {{! template-lint-configure simple-unless "warn" }}
- {{#unless (and (not-eq @mode "create") (eq attr.name "name"))}}
+ {{! SHOW ALL FIELDS IF CREATING A NEW MODEL }}
+ {{#if (or (eq @mode "create") @model.isNew)}}
- {{/unless}}
+ {{else}}
+ {{! OTHERWISE WE'RE EDITING }}
+ {{#if (or (eq attr.name "name") (attr.options.editDisabled))}}
+
+ {{else}}
+
+ {{/if}}
+ {{/if}}
{{/each}}
{{else}}
{{#each fields as |attr|}}
-
+ {{! SHOW ALL FIELDS IF CREATING A NEW MODEL }}
+ {{#if (or (eq @mode "create") @model.isNew)}}
+
+ {{else}}
+ {{! OTHERWISE WE'RE EDITING }}
+ {{#if (or (eq attr.name "name") (eq attr.options.editDisabled true))}}
+
+ {{else}}
+
+ {{/if}}
+ {{/if}}
{{/each}}
{{/if}}
diff --git a/ui/lib/core/addon/components/form-field-label.hbs b/ui/lib/core/addon/components/form-field-label.hbs
index a6779bba6..f6de039c9 100644
--- a/ui/lib/core/addon/components/form-field-label.hbs
+++ b/ui/lib/core/addon/components/form-field-label.hbs
@@ -12,9 +12,9 @@
{{@subText}}
{{#if @docLink}}
-
+
See our documentation
-
+
for help.
{{/if}}
diff --git a/ui/lib/core/addon/components/form-field.hbs b/ui/lib/core/addon/components/form-field.hbs
index a877a6269..2a42b76b1 100644
--- a/ui/lib/core/addon/components/form-field.hbs
+++ b/ui/lib/core/addon/components/form-field.hbs
@@ -1,5 +1,5 @@
{{! template-lint-configure simple-unless "warn" }}
-
+
{{#unless
(or
(eq @attr.type "boolean")
@@ -21,9 +21,9 @@
{{/unless}}
{{#if @attr.options.possibleValues}}
{{#if (eq @attr.options.editType "radio")}}
-
+
{{#each (path-or-array @attr.options.possibleValues @model) as |val|}}
-
+
{{or val.value val}}
@@ -91,8 +92,13 @@
@selectLimit={{@attr.options.selectLimit}}
@backend={{@model.backend}}
@disallowNewItems={{@attr.options.onlyAllowExisting}}
+ @labelClass={{@labelClass}}
+ class={{if this.validationError "dropdown-has-error-border"}}
/>
+ {{#if this.validationError}}
+
+ {{/if}}
{{else if (eq @attr.options.editType "mountAccessor")}}
{{@attr.options.subText}}
{{#if @attr.options.docLink}}
-
+
See our documentation
-
+
for help.
{{/if}}
@@ -173,9 +179,9 @@
{{or @attr.options.defaultSubText "Vault will use the engine default."}}
{{#if @attr.options.docLink}}
-
+
See our documentation
-
+
for help.
{{/if}}
@@ -240,38 +246,41 @@
/>
{{else if (eq @attr.options.editType "json")}}
{{! JSON Editor }}
-
- {{#if @attr.options.allowReset}}
-
- Clear
-
-
- {{/if}}
-
+ {{#let (get @model this.valuePath) as |value|}}
+
+ {{#if @attr.options.allowReset}}
+
+ Clear
+
+
+ {{/if}}
+
+ {{/let}}
{{#if @attr.options.subText}}
{{@attr.options.subText}}
{{#if @attr.options.docLink}}
-
+
See our documentation
-
+
for help.
{{/if}}
@@ -282,6 +291,7 @@
data-test-input={{@attr.name}}
id={{@attr.name}}
readonly={{this.isReadOnly}}
+ disabled={{and @attr.options.editDisabled (not @model.isNew)}}
autocomplete="off"
spellcheck="false"
value={{or (get @model this.valuePath) @attr.options.defaultValue}}
diff --git a/ui/lib/core/addon/components/info-table-item-array.hbs b/ui/lib/core/addon/components/info-table-item-array.hbs
index d1db9818c..d4bd43604 100644
--- a/ui/lib/core/addon/components/info-table-item-array.hbs
+++ b/ui/lib/core/addon/components/info-table-item-array.hbs
@@ -1,58 +1,79 @@
-
+{{! the class linkable-item is needed for the read-more component }}
+
{{#if @isLink}}
- {{#each this.displayArrayAmended as |name|}}
- {{#if (is-wildcard-string name)}}
- {{#let (filter-wildcard name this.allOptions) as |wildcardCount|}}
- {{name}}
-
- includes
- {{if wildcardCount wildcardCount 0}}
- {{if (eq wildcardCount 1) @wildcardLabel (pluralize @wildcardLabel)}}
-
- {{#if (eq this.displayArrayAmended.lastObject name)}}
-
- View all {{@viewAll}}
+
+ {{#each this.displayArrayTruncated as |name|}}
+ {{#if (is-wildcard-string name)}}
+ {{#let (filter-wildcard name this.allOptions) as |wildcardCount|}}
+ {{name}}
+
+ includes
+ {{if wildcardCount wildcardCount 0}}
+ {{if (eq wildcardCount 1) @wildcardLabel (pluralize @wildcardLabel)}}
+
+ {{#if (eq this.displayArrayTruncated.lastObject name)}}
+
+ View all {{lowercase @label}}.
+
+ {{/if}}
+ {{/let}}
+ {{else}}
+ {{#if (is-array this.itemRoute)}}
+
+ {{name}}
+
+ {{else}}
+
+ {{name}}
{{/if}}
- {{/let}}
- {{else}}
-
- {{name}}
-
- {{/if}}
- {{#if
- (or
- (and (not-eq name this.displayArrayAmended.lastObject) this.wildcardInDisplayArray)
- (not-eq name this.displayArrayAmended.lastObject)
- )
- }}
- ,
- {{/if}}
- {{#if (and (eq name this.displayArrayAmended.lastObject) (gte this.displayArray.length 10))}}
-
- and
- {{dec 5 this.displayArray.length}}
- others.
-
- {{/if}}
- {{#if (and (eq name this.displayArrayAmended.lastObject) (gte this.displayArray.length 10))}}
-
- View all {{this.viewAll}}
-
- {{/if}}
- {{/each}}
+ {{/if}}
+ {{#if
+ (or
+ (and (not-eq name this.displayArrayTruncated.lastObject) this.wildcardInDisplayArray)
+ (not-eq name this.displayArrayTruncated.lastObject)
+ )
+ }}
+ ,
+ {{/if}}
+ {{#unless this.doNotTruncate}}
+ {{#if (and (eq name this.displayArrayTruncated.lastObject) (gte @displayArray.length 10))}}
+ {{! dec is a math helper that decrements by 5 the length of the array ex: 11-5 = "and 6 others."}}
+
+ and
+ {{dec 5 @displayArray.length}}
+ others.
+
+ {{/if}}
+ {{#if (and (eq name this.displayArrayTruncated.lastObject) (gte @displayArray.length 10))}}
+ {{#if (is-array @rootRoute)}}
+
+ View all {{lowercase @label}}.
+
+ {{else}}
+
+ View all {{lowercase @label}}.
+
+ {{/if}}
+ {{/if}}
+ {{/unless}}
+ {{/each}}
+
{{else}}
{{if
- (gte this.displayArray.length 10)
- (concat this.displayArray ", and " (dec 5 this.displayArray.length) " more.")
- this.displayArray
+ (gte @displayArray.length 10)
+ (concat @displayArray ", and " (dec 5 @displayArray.length) " more.")
+ @displayArray
}}
{{/if}}
diff --git a/ui/lib/core/addon/components/info-table-item-array.js b/ui/lib/core/addon/components/info-table-item-array.js
index 7d3dcad27..2c316de6e 100644
--- a/ui/lib/core/addon/components/info-table-item-array.js
+++ b/ui/lib/core/addon/components/info-table-item-array.js
@@ -15,25 +15,25 @@ import { isWildcardString } from 'vault/helpers/is-wildcard-string';
* @example
* ```js
*
* ```
*
- * @param displayArray=null {array} - This array of data to be displayed. If there are more than 10 items in the array only five will show and a count of the other number in the array will show.
- * @param [isLink] {Boolean} - Indicates if the item should contain a link-to component. Only setup for arrays, but this could be changed if needed.
- * @param [rootRoute="vault.cluster.secrets.backend.list-root"] - {string} - Tells what route the link should go to when selecting "view all".
- * @param [itemRoute=vault.cluster.secrets.backend.show] - {string} - Tells what route the link should go to when selecting the individual item.
- * @param [modelType] {string} - Tells which model you want data for the allOptions to be returned from. Used in conjunction with the the isLink.
- * @param [wildcardLabel] {String} - when you want the component to return a count on the model for options returned when using a wildcard you must provide a label of the count e.g. role. Should be singular.
- * @param [queryParam] {String} - If you want to specific a tab for the View All XX to display to. Ex: role
- * @param [backend] {String} - To specify which backend to point the link to.
- * @param [viewAll] {String} - Specify the word at the end of the link View all xx.
+ * @param {string} label - used to render lowercased display text for "View all ."
+ * @param {array} displayArray - The array of data to be displayed. (In InfoTableRow this comes from the @value arg.) If the array length > 10, and @doNotTruncate is false only 5 will show with a count of the number hidden.
+ * @param {boolean} [isLink] - Indicates if the item should contain a link-to component. Only setup for arrays, but this could be changed if needed.
+ * @param {string || array} [rootRoute="vault.cluster.secrets.backend.list-root"] - Tells what route the link should go to when selecting "view all". If the route requires more than one dynamic param, insert an array.
+ * @param {string || array} [itemRoute=vault.cluster.secrets.backend.show] - Tells what route the link should go to when selecting the individual item. If the route requires more than one dynamic param, insert an array.
+ * @param {string} [modelType] - Tells which model you want data for the allOptions to be returned from. Used in conjunction with the the isLink.
+ * @param {string} [wildcardLabel] - when you want the component to return a count on the model for options returned when using a wildcard you must provide a label of the count e.g. role. Should be singular.
+ * @param {string} [backend] - To specify which backend to point the link to.
+ * @param {boolean} [doNotTruncate=false] - Determines whether to show the View all "roles" link.
*/
export default class InfoTableItemArray extends Component {
@tracked allOptions = null;
@@ -48,26 +48,25 @@ export default class InfoTableItemArray extends Component {
return this.args.itemRoute || 'vault.cluster.secrets.backend.show';
}
- get displayArray() {
- return this.args.displayArray || null;
+ get doNotTruncate() {
+ return this.args.doNotTruncate || false;
}
- get displayArrayAmended() {
- let { displayArray } = this;
+ get displayArrayTruncated() {
+ let { displayArray } = this.args;
if (!displayArray) return null;
- if (displayArray.length >= 10) {
+ if ((displayArray.length >= 10) & !this.args.doNotTruncate) {
// if array greater than 10 in length only display the first 5
- displayArray = displayArray.slice(0, 5);
+ return displayArray.slice(0, 5);
}
return displayArray;
}
async checkWildcardInArray() {
- if (!this.displayArray) {
+ if (!this.args.displayArray) {
return;
}
- let filteredArray = await this.displayArray.filter((item) => isWildcardString(item));
-
+ let filteredArray = await this.args.displayArray.filter((item) => isWildcardString([item]));
this.wildcardInDisplayArray = filteredArray.length > 0 ? true : false;
}
diff --git a/ui/lib/core/addon/components/info-table-row.hbs b/ui/lib/core/addon/components/info-table-row.hbs
index da508d676..6e8d5e815 100644
--- a/ui/lib/core/addon/components/info-table-row.hbs
+++ b/ui/lib/core/addon/components/info-table-row.hbs
@@ -29,7 +29,19 @@
{{/if}}
-
+
+ {{#if @addCopyButton}}
+
+
+
+
+
+ {{/if}}
{{#if (has-block)}}
{{yield}}
{{else if this.valueIsBoolean}}
@@ -40,7 +52,7 @@
No
{{/if}}
- {{! alwaysRender is still true }}
+ {{! @alwaysRender (this.isVisible) is still true }}
{{else if this.valueIsEmpty}}
{{#if @defaultShown}}
{{@defaultShown}}
@@ -52,14 +64,16 @@
{{else}}
{{#if (eq @type "array")}}
{{else}}
{{#if @tooltipText}}
diff --git a/ui/lib/core/addon/components/radio-button.hbs b/ui/lib/core/addon/components/radio-button.hbs
index 901962a23..0917c85a9 100644
--- a/ui/lib/core/addon/components/radio-button.hbs
+++ b/ui/lib/core/addon/components/radio-button.hbs
@@ -1 +1,8 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/ui/lib/core/addon/components/read-more.js b/ui/lib/core/addon/components/read-more.js
index a8a2b3488..7b53df2d6 100644
--- a/ui/lib/core/addon/components/read-more.js
+++ b/ui/lib/core/addon/components/read-more.js
@@ -6,7 +6,8 @@ import { tracked } from '@glimmer/tracking';
/**
* @module ReadMore
- * ReadMore components are used to wrap long text that we'd like to show as one line initially with the option to expand and read. Text which is shorter than the surrounding div will not truncate or show the See More button.
+ * ReadMore components are used to wrap long text that we'd like to show as one line initially with the option to expand and read.
+ * Text which is shorter than the surrounding div will not truncate or show the See More button.
*
* @example
* ```js
diff --git a/ui/lib/core/addon/components/search-select-with-modal.hbs b/ui/lib/core/addon/components/search-select-with-modal.hbs
index e441647f5..600ba8106 100644
--- a/ui/lib/core/addon/components/search-select-with-modal.hbs
+++ b/ui/lib/core/addon/components/search-select-with-modal.hbs
@@ -75,7 +75,7 @@
{{/if}}
{{#if this.newModelRecord}}
{},
inputValue: computed(function () {
return [];
@@ -56,7 +57,11 @@ export default Component.extend({
shouldRenderName: false,
disallowNewItems: false,
passObject: false,
-
+ objectKeys: null,
+ idKey: computed('objectKeys', function () {
+ // if objectKeys exists, then use the first element of the array as the identifier
+ return this.objectKeys ? this.objectKeys[0] : 'id';
+ }),
init() {
this._super(...arguments);
this.set('selectedOptions', this.inputValue || []);
@@ -73,7 +78,7 @@ export default Component.extend({
},
formatOptions: function (options) {
options = options.toArray().map((option) => {
- option.searchText = `${option.name} ${option.id}`;
+ option.searchText = `${option.name} ${option[this.idKey]}`;
return option;
});
let allOptions = options.toArray().map((option) => {
@@ -81,15 +86,19 @@ export default Component.extend({
});
this.set('allOptions', allOptions); // used by filter-wildcard helper
let formattedOptions = this.selectedOptions.map((option) => {
- let matchingOption = options.findBy('id', option);
+ let matchingOption = options.findBy(this.idKey, option);
+ // an undefined matchingOption means a selectedOption, on edit, didn't match a model returned from the query and therefore doesn't exist
+ let addTooltip = matchingOption ? false : true; // add tooltip to let user know the selection can be discarded
options.removeObject(matchingOption);
return {
id: option,
name: matchingOption ? matchingOption.name : option,
searchText: matchingOption ? matchingOption.searchText : option,
+ addTooltip,
+ // conditionally spread configured object if we're using the dynamic idKey
+ ...(this.idKey !== 'id' && this.customizeObject(matchingOption)),
};
});
-
this.set('selectedOptions', formattedOptions);
if (this.options) {
options = this.options.concat(options).uniq();
@@ -103,6 +112,10 @@ export default Component.extend({
}
return;
}
+ if (this.idKey !== 'id') {
+ // if passing a dynamic idKey, then display it in the dropdown beside the name
+ this.set('shouldRenderName', true);
+ }
for (let modelType of this.models) {
if (modelType.includes('identity')) {
this.set('shouldRenderName', true);
@@ -112,6 +125,9 @@ export default Component.extend({
if (this.backend) {
queryOptions = { backend: this.backend };
}
+ if (this.queryObject) {
+ queryOptions = this.queryObject;
+ }
let options = yield this.store.query(modelType, queryOptions);
this.formatOptions(options);
} catch (err) {
@@ -135,11 +151,7 @@ export default Component.extend({
}).on('didInsertElement'),
handleChange() {
if (this.selectedOptions.length && typeof this.selectedOptions.firstObject === 'object') {
- if (this.passObject) {
- this.onChange(Array.from(this.selectedOptions, (option) => ({ id: option.id, isNew: !!option.new })));
- } else {
- this.onChange(Array.from(this.selectedOptions, (option) => option.id));
- }
+ this.onChange(Array.from(this.selectedOptions, (option) => this.customizeObject(option)));
} else {
this.onChange(this.selectedOptions);
}
@@ -157,7 +169,7 @@ export default Component.extend({
//----- adapted from ember-power-select-with-create
addCreateOption(term, results) {
if (this.shouldShowCreate(term, results)) {
- const name = `Add new ${singularize(this.label)}: ${term}`;
+ const name = `Add new ${singularize(this.label || 'item')}: ${term}`;
const suggestion = {
__isSuggestion__: true,
__value__: term,
@@ -172,7 +184,30 @@ export default Component.extend({
return filterOptions(options || [], searchText, matcher);
},
// -----
-
+ customizeObject(option) {
+ if (!option) return;
+ // if passObject=true return object, otherwise return string of option id
+ if (this.passObject) {
+ let additionalKeys;
+ if (this.objectKeys) {
+ // pull attrs corresponding to objectKeys from model record, add to the selected option (object) and send to the parent
+ additionalKeys = Object.fromEntries(this.objectKeys.map((key) => [key, option[key]]));
+ // filter any undefined attrs, which means the model did not have a value for that attr
+ // no value could mean the model was not hydrated, the record is new or the model doesn't have that attribute
+ Object.keys(additionalKeys).forEach((key) => {
+ if (additionalKeys[key] === undefined) {
+ delete additionalKeys[key];
+ }
+ });
+ }
+ return {
+ id: option.id,
+ isNew: !!option.new,
+ ...additionalKeys,
+ };
+ }
+ return option.id;
+ },
actions: {
onChange(val) {
this.onChange(val);
diff --git a/ui/lib/core/addon/components/tool-tip.js b/ui/lib/core/addon/components/tool-tip.js
index 7f8f0f705..88c893f48 100644
--- a/ui/lib/core/addon/components/tool-tip.js
+++ b/ui/lib/core/addon/components/tool-tip.js
@@ -7,7 +7,7 @@ export default class ToolTipComponent extends Component {
return this.args.delay || 200;
}
get horizontalPosition() {
- return this.args.delay || 'auto-right';
+ return this.args.horizontalPosition || 'auto-right';
}
toggleState({ dropdown, action }) {
diff --git a/ui/lib/core/addon/templates/components/box-radio.hbs b/ui/lib/core/addon/templates/components/box-radio.hbs
index edfa73c46..8f65b4df4 100644
--- a/ui/lib/core/addon/templates/components/box-radio.hbs
+++ b/ui/lib/core/addon/templates/components/box-radio.hbs
@@ -14,7 +14,7 @@
id={{@type}}
name={{@groupName}}
class="radio"
- disabled={{@disabled}}
+ @disabled={{@disabled}}
@value={{@type}}
@groupValue={{@groupValue}}
@onChange={{@onRadioChange}}
@@ -42,7 +42,7 @@
id={{@type}}
name={{@groupName}}
class="radio"
- disabled={{@disabled}}
+ @disabled={{@disabled}}
@value={{@type}}
@groupValue={{@mountType}}
@onChange={{@onRadioChange}}
diff --git a/ui/lib/core/addon/templates/components/readonly-form-field.hbs b/ui/lib/core/addon/templates/components/readonly-form-field.hbs
index fac4da9dd..3e7552e35 100644
--- a/ui/lib/core/addon/templates/components/readonly-form-field.hbs
+++ b/ui/lib/core/addon/templates/components/readonly-form-field.hbs
@@ -29,6 +29,7 @@
autocomplete="off"
spellcheck="false"
value={{@value}}
+ disabled={{true}}
readonly
class="field input is-readOnly"
type={{@attr.type}}
diff --git a/ui/lib/core/addon/templates/components/search-select.hbs b/ui/lib/core/addon/templates/components/search-select.hbs
index 1f75e4ca0..00cab345e 100644
--- a/ui/lib/core/addon/templates/components/search-select.hbs
+++ b/ui/lib/core/addon/templates/components/search-select.hbs
@@ -11,7 +11,7 @@
}}
{{else}}
{{#if this.label}}
-
+
{{this.label}}
{{#if this.helpText}}
{{this.helpText}}
@@ -40,7 +40,7 @@
{{#if this.shouldRenderName}}
{{option.name}}
- {{option.id}}
+ {{get option this.idKey}}
{{else}}
{{option.id}}
@@ -48,12 +48,12 @@
{{/unless}}
- {{#each this.selectedOptions as |selected|}}
-
+ {{#each this.selectedOptions as |selected index|}}
+
{{#if this.shouldRenderName}}
{{selected.name}}
- {{selected.id}}
+ {{get selected this.idKey}}
{{else}}
@@ -72,6 +72,13 @@
{{/if}}
+ {{#if selected.addTooltip}}
+
+ The item with this
+ {{to-label this.idKey}}
+ no longer exists and can safely be removed.
+
+ {{/if}}
({
+ data: {
+ key_info: { '1234-12345': { name: 'test-entity' } },
+ keys: ['1234-12345'],
+ },
+ }));
+
+ // GROUP SEARCH SELECT
+ server.get('/identity/group/id', () => ({
+ data: {
+ key_info: { 'abcdef-123': { name: 'test-group' } },
+ keys: ['abcdef-123'],
+ },
+ }));
+}
diff --git a/ui/public/images/oidc-landing.png b/ui/public/images/oidc-landing.png
new file mode 100644
index 000000000..ec0061256
--- /dev/null
+++ b/ui/public/images/oidc-landing.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:c5310ac5770d2bfc6aa3447a19ca3d31ef665f997ec09c84c4c5605c8d0c48eb
+size 56303
diff --git a/ui/tests/acceptance/oidc-config/clients-assignments-test.js b/ui/tests/acceptance/oidc-config/clients-assignments-test.js
new file mode 100644
index 000000000..000477690
--- /dev/null
+++ b/ui/tests/acceptance/oidc-config/clients-assignments-test.js
@@ -0,0 +1,349 @@
+import { module, test } from 'qunit';
+import { visit, currentURL, click, fillIn, findAll, currentRouteName } from '@ember/test-helpers';
+import { setupApplicationTest } from 'ember-qunit';
+import { setupMirage } from 'ember-cli-mirage/test-support';
+import ENV from 'vault/config/environment';
+import authPage from 'vault/tests/pages/auth';
+import logout from 'vault/tests/pages/logout';
+import { create } from 'ember-cli-page-object';
+import { clickTrigger } from 'ember-power-select/test-support/helpers';
+import ss from 'vault/tests/pages/components/search-select';
+import fm from 'vault/tests/pages/components/flash-message';
+import {
+ OIDC_BASE_URL, // -> '/vault/access/oidc'
+ SELECTORS,
+ clearRecord,
+ overrideCapabilities,
+ overrideMirageResponse,
+ ASSIGNMENT_LIST_RESPONSE,
+ ASSIGNMENT_DATA_RESPONSE,
+} from 'vault/tests/helpers/oidc-config';
+const searchSelect = create(ss);
+const flashMessage = create(fm);
+
+module('Acceptance | oidc-config clients and assignments', function (hooks) {
+ setupApplicationTest(hooks);
+ setupMirage(hooks);
+
+ hooks.before(function () {
+ ENV['ember-cli-mirage'].handler = 'oidcConfig';
+ });
+
+ hooks.beforeEach(async function () {
+ this.store = await this.owner.lookup('service:store');
+ return authPage.login();
+ });
+
+ hooks.afterEach(function () {
+ return logout.visit();
+ });
+
+ hooks.after(function () {
+ ENV['ember-cli-mirage'].handler = null;
+ });
+
+ test('it renders only allow_all when no assignments are configured', async function (assert) {
+ assert.expect(3);
+
+ //* clear out test state
+ await clearRecord(this.store, 'oidc/assignment', 'test-assignment');
+
+ await visit(OIDC_BASE_URL + '/assignments');
+ assert.equal(currentURL(), '/vault/access/oidc/assignments');
+ assert.dom('[data-test-tab="assignments"]').hasClass('active', 'assignments tab is active');
+ assert
+ .dom('[data-test-oidc-assignment-linked-block="allow_all"]')
+ .hasClass('is-disabled', 'renders default allow all assignment and is disabled.');
+ });
+
+ test('it renders empty state when no clients are configured', async function (assert) {
+ assert.expect(5);
+ this.server.get('/identity/oidc/client', () => overrideMirageResponse(404));
+
+ await visit(OIDC_BASE_URL);
+ assert.equal(currentURL(), '/vault/access/oidc');
+ assert.dom('h1.title.is-3').hasText('OIDC Provider');
+ assert.dom(SELECTORS.oidcHeader).hasText(
+ `Configure Vault to act as an OIDC identity provider, and offer Vault’s various authentication
+ methods and source of identity to any client applications. Learn more Create your first app`,
+ 'renders call to action header when no clients are configured'
+ );
+ assert.dom('[data-test-oidc-landing]').exists('landing page renders when no clients are configured');
+ assert
+ .dom(SELECTORS.oidcLandingImg)
+ .hasAttribute('src', '/ui/images/oidc-landing.png', 'image renders image when no clients configured');
+ });
+
+ test('it creates an assignment inline, creates a client, updates client to limit access, deletes client', async function (assert) {
+ assert.expect(22);
+
+ //* clear out test state
+ await clearRecord(this.store, 'oidc/client', 'test-app');
+ await clearRecord(this.store, 'oidc/client', 'my-webapp'); // created by oidc-provider-test
+ await clearRecord(this.store, 'oidc/assignment', 'assignment-inline');
+
+ // create a client with allow all access
+ await visit(OIDC_BASE_URL + '/clients/create');
+ assert.equal(currentRouteName(), 'vault.cluster.access.oidc.clients.create', 'navigates to create form');
+ await fillIn('[data-test-input="name"]', 'test-app');
+ await click('[data-test-toggle-group="More options"]');
+ // toggle ttls to false, testing it sets correct default duration
+ await click('[data-test-input="idTokenTtl"]');
+ await click('[data-test-input="accessTokenTtl"]');
+ await click(SELECTORS.clientSaveButton);
+ assert.equal(
+ flashMessage.latestMessage,
+ 'Successfully created the application test-app.',
+ 'renders success flash upon client creation'
+ );
+ assert.equal(
+ currentRouteName(),
+ 'vault.cluster.access.oidc.clients.client.details',
+ 'navigates to client details view after save'
+ );
+ // assert default values in details view are correct
+ assert.dom('[data-test-value-div="Assignment"]').hasText('allow_all', 'client allows all assignments');
+ assert.dom('[data-test-value-div="Type"]').hasText('confidential', 'type defaults to confidential');
+ assert
+ .dom('[data-test-value-div="Key"] a')
+ .hasText('default', 'client uses default key and renders a link');
+ assert
+ .dom('[data-test-value-div="Client ID"] [data-test-copy-button]')
+ .exists('client ID exists and has copy button');
+ assert
+ .dom('[data-test-value-div="Client Secret"] [data-test-copy-button]')
+ .exists('client secret exists and has copy button');
+ assert
+ .dom('[data-test-value-div="ID Token TTL"]')
+ .hasText('1 day', 'ID token ttl toggled off sets default of 24h');
+ assert
+ .dom('[data-test-value-div="Access Token TTL"]')
+ .hasText('1 day', 'access token ttl toggled off sets default of 24h');
+
+ // edit client
+ await click(SELECTORS.clientDetailsTab);
+ await click(SELECTORS.clientEditButton);
+ assert.equal(
+ currentRouteName(),
+ 'vault.cluster.access.oidc.clients.client.edit',
+ 'navigates to edit page from details'
+ );
+ await fillIn('[data-test-input="redirectUris"] [data-test-string-list-input="0"]', 'some-url.com');
+
+ // limit access & create new assignment inline
+ await click('label[for=limited]');
+ await clickTrigger();
+ await fillIn('.ember-power-select-search input', 'assignment-inline');
+ await searchSelect.options.objectAt(0).click();
+ await click('[data-test-search-select="entities"] .ember-basic-dropdown-trigger');
+ await searchSelect.options.objectAt(0).click();
+ await click('[data-test-search-select="groups"] .ember-basic-dropdown-trigger');
+ await searchSelect.options.objectAt(0).click();
+ await click(SELECTORS.assignmentSaveButton);
+ assert.equal(
+ flashMessage.latestMessage,
+ 'Successfully created the assignment assignment-inline.',
+ 'renders success flash upon assignment creating'
+ );
+ await click(SELECTORS.clientSaveButton);
+ assert.equal(
+ flashMessage.latestMessage,
+ 'Successfully updated the application test-app.',
+ 'renders success flash upon client updating'
+ );
+ assert.equal(
+ currentRouteName(),
+ 'vault.cluster.access.oidc.clients.client.details',
+ 'navigates back to details on update'
+ );
+ assert.dom('[data-test-value-div="Redirect URI"]').hasText('some-url.com', 'shows updated attribute');
+ assert
+ .dom('[data-test-value-div="Assignment"]')
+ .hasText('assignment-inline', 'updated to limited assignment');
+
+ // edit back to allow_all
+ await click(SELECTORS.clientEditButton);
+ assert.dom(SELECTORS.clientSaveButton).hasText('Update', 'form button renders correct text');
+ await click('label[for=allow-all]');
+ await click(SELECTORS.clientSaveButton);
+ assert
+ .dom('[data-test-value-div="Assignment"]')
+ .hasText('allow_all', 'client updated to allow all assignments');
+
+ // create another client
+ await visit(OIDC_BASE_URL + '/clients/create');
+ await fillIn('[data-test-input="name"]', 'app-to-delete');
+ await click(SELECTORS.clientSaveButton);
+
+ // immediately delete client, test transition
+ await click(SELECTORS.clientDeleteButton);
+ await click(SELECTORS.confirmActionButton);
+ assert.equal(
+ flashMessage.latestMessage,
+ 'Application deleted successfully',
+ 'renders success flash upon deleting client'
+ );
+ assert.equal(
+ currentRouteName(),
+ 'vault.cluster.access.oidc.clients.index',
+ 'navigates back to list view after delete'
+ );
+ // delete last client
+ await click('[data-test-oidc-client-linked-block]');
+ assert.equal(currentRouteName(), 'vault.cluster.access.oidc.clients.client.details');
+ await click(SELECTORS.clientDeleteButton);
+ await click(SELECTORS.confirmActionButton);
+ assert.equal(
+ currentRouteName(),
+ 'vault.cluster.access.oidc.index',
+ 'redirects to call to action if only existing client is deleted'
+ );
+ //* clean up test state
+ await clearRecord(this.store, 'oidc/assignment', 'assignment-inline');
+ });
+
+ test('it creates, updates, and deletes an assignment', async function (assert) {
+ assert.expect(12);
+ await visit(OIDC_BASE_URL + '/assignments');
+
+ //* ensure clean test state
+ await clearRecord(this.store, 'oidc/assignment', 'test-assignment');
+
+ // create a new assignment
+ await click(SELECTORS.assignmentCreateButton);
+ assert.equal(
+ currentRouteName(),
+ 'vault.cluster.access.oidc.assignments.create',
+ 'navigates to create form'
+ );
+ await fillIn('[data-test-input="name"]', 'test-assignment');
+ await click('[data-test-component="search-select"]#entities .ember-basic-dropdown-trigger');
+ await click('.ember-power-select-option');
+ await click(SELECTORS.assignmentSaveButton);
+ assert.equal(
+ flashMessage.latestMessage,
+ 'Successfully created the assignment test-assignment.',
+ 'renders success flash upon creating the assignment'
+ );
+ assert.equal(
+ currentRouteName(),
+ 'vault.cluster.access.oidc.assignments.assignment.details',
+ 'navigates to the assignments detail view after save'
+ );
+
+ // assert default values in assignment details view are correct
+ assert.dom('[data-test-value-div="Name"]').hasText('test-assignment');
+ assert.dom('[data-test-value-div="Entities"]').hasText('1234-12345', 'shows the entity id.');
+
+ // edit assignment
+ await click(SELECTORS.assignmentEditButton);
+ assert.equal(
+ currentRouteName(),
+ 'vault.cluster.access.oidc.assignments.assignment.edit',
+ 'navigates to the assignment edit page from details'
+ );
+ await click('[data-test-component="search-select"]#groups .ember-basic-dropdown-trigger');
+ await click('.ember-power-select-option');
+ assert.dom('[data-test-oidc-assignment-save]').hasText('Update');
+ await click(SELECTORS.assignmentSaveButton);
+ assert.equal(
+ flashMessage.latestMessage,
+ 'Successfully updated the assignment test-assignment.',
+ 'renders success flash upon updating the assignment'
+ );
+
+ assert.dom('[data-test-value-div="Entities"]').hasText('1234-12345', 'it still shows the entity id.');
+ assert.dom('[data-test-value-div="Groups"]').hasText('abcdef-123', 'shows updated group name id.');
+
+ // delete the assignment
+ await click(SELECTORS.assignmentDeleteButton);
+ await click(SELECTORS.confirmActionButton);
+ assert.equal(
+ flashMessage.latestMessage,
+ 'Assignment deleted successfully',
+ 'renders success flash upon deleting assignment'
+ );
+ assert.equal(
+ currentRouteName(),
+ 'vault.cluster.access.oidc.assignments.index',
+ 'navigates back to assignment list view after delete'
+ );
+ });
+
+ test('it navigates to and from an assignment from the list view', async function (assert) {
+ assert.expect(6);
+ this.server.get('/identity/oidc/assignment', () =>
+ overrideMirageResponse(null, ASSIGNMENT_LIST_RESPONSE)
+ );
+ this.server.get('/identity/oidc/assignment/test-assignment', () =>
+ overrideMirageResponse(null, ASSIGNMENT_DATA_RESPONSE)
+ );
+ await visit(OIDC_BASE_URL + '/assignments');
+ assert
+ .dom('[data-test-oidc-assignment-linked-block="test-assignment"]')
+ .exists('displays linked block for test-assignment');
+
+ await click(SELECTORS.assignmentCreateButton);
+ assert.equal(
+ currentRouteName(),
+ 'vault.cluster.access.oidc.assignments.create',
+ 'assignments index toolbar navigates to create form'
+ );
+ await click(SELECTORS.assignmentCancelButton);
+ assert.equal(
+ currentRouteName(),
+ 'vault.cluster.access.oidc.assignments.index',
+ 'create form navigates back to assignment index on cancel'
+ );
+
+ await click('[data-test-popup-menu-trigger]');
+ await click('[data-test-oidc-assignment-menu-link="edit"]');
+ assert.equal(
+ currentRouteName(),
+ 'vault.cluster.access.oidc.assignments.assignment.edit',
+ 'linked block popup menu navigates to edit'
+ );
+ await click(SELECTORS.assignmentCancelButton);
+ assert.equal(
+ currentRouteName(),
+ 'vault.cluster.access.oidc.assignments.assignment.details',
+ 'edit form navigates back to assignment details on cancel'
+ );
+ // navigate to details from index page
+ await visit('/vault/access/oidc/assignments');
+ await click('[data-test-popup-menu-trigger]');
+ await click('[data-test-oidc-assignment-menu-link="details"]');
+ assert.equal(
+ currentRouteName(),
+ 'vault.cluster.access.oidc.assignments.assignment.details',
+ 'popup menu navigates to assignment details'
+ );
+ });
+
+ test('it hides assignment delete and edit when no permission', async function (assert) {
+ assert.expect(5);
+ this.server.get('/identity/oidc/assignment', () =>
+ overrideMirageResponse(null, ASSIGNMENT_LIST_RESPONSE)
+ );
+ this.server.get('/identity/oidc/assignment/test-assignment', () =>
+ overrideMirageResponse(null, ASSIGNMENT_DATA_RESPONSE)
+ );
+ this.server.post('/sys/capabilities-self', () =>
+ overrideCapabilities(OIDC_BASE_URL + '/assignment/test-assignment', ['read'])
+ );
+
+ await visit(OIDC_BASE_URL + '/assignments');
+ await click('[data-test-oidc-assignment-linked-block="test-assignment"]');
+ assert
+ .dom('[data-test-oidc-assignment-title]')
+ .hasText('test-assignment', 'renders assignment name as title');
+ assert.dom(SELECTORS.assignmentDetailsTab).hasClass('active', 'details tab is active');
+ assert.dom(SELECTORS.assignmentDeleteButton).doesNotExist('delete option is hidden');
+ assert.dom(SELECTORS.assignmentEditButton).doesNotExist('edit button is hidden');
+ assert.equal(
+ findAll('[data-test-component="info-table-row"]').length,
+ 3,
+ 'renders all assignment info rows'
+ );
+ });
+});
diff --git a/ui/tests/acceptance/oidc-config/clients-keys-test.js b/ui/tests/acceptance/oidc-config/clients-keys-test.js
new file mode 100644
index 000000000..e664e999f
--- /dev/null
+++ b/ui/tests/acceptance/oidc-config/clients-keys-test.js
@@ -0,0 +1,305 @@
+import { module, test } from 'qunit';
+import { visit, click, fillIn, findAll, currentRouteName } from '@ember/test-helpers';
+import { setupApplicationTest } from 'ember-qunit';
+import { setupMirage } from 'ember-cli-mirage/test-support';
+import ENV from 'vault/config/environment';
+import authPage from 'vault/tests/pages/auth';
+import logout from 'vault/tests/pages/logout';
+import { create } from 'ember-cli-page-object';
+import { clickTrigger, selectChoose } from 'ember-power-select/test-support/helpers';
+import ss from 'vault/tests/pages/components/search-select';
+import fm from 'vault/tests/pages/components/flash-message';
+import {
+ OIDC_BASE_URL, // -> '/vault/access/oidc'
+ SELECTORS,
+ clearRecord,
+ overrideCapabilities,
+ overrideMirageResponse,
+ CLIENT_LIST_RESPONSE,
+ CLIENT_DATA_RESPONSE,
+} from 'vault/tests/helpers/oidc-config';
+const searchSelect = create(ss);
+const flashMessage = create(fm);
+
+// in congruency with backend verbiage 'applications' are referred to as 'clients'
+// throughout the codebase and the term 'applications' only appears in the UI
+
+module('Acceptance | oidc-config clients and keys', function (hooks) {
+ setupApplicationTest(hooks);
+ setupMirage(hooks);
+
+ hooks.before(function () {
+ ENV['ember-cli-mirage'].handler = 'oidcConfig';
+ });
+
+ hooks.beforeEach(async function () {
+ this.store = await this.owner.lookup('service:store');
+ return authPage.login();
+ });
+
+ hooks.afterEach(function () {
+ return logout.visit();
+ });
+
+ hooks.after(function () {
+ ENV['ember-cli-mirage'].handler = null;
+ });
+
+ test('it creates a key, signs a client and edits key access to only that client', async function (assert) {
+ assert.expect(21);
+
+ //* start with clean test state
+ await clearRecord(this.store, 'oidc/client', 'client-with-test-key');
+ await clearRecord(this.store, 'oidc/client', 'client-with-default-key');
+ await clearRecord(this.store, 'oidc/key', 'test-key');
+
+ // create client with default key
+ await visit(OIDC_BASE_URL + '/clients/create');
+ await fillIn('[data-test-input="name"]', 'client-with-default-key');
+ await click(SELECTORS.clientSaveButton);
+
+ // check reroutes from oidc index to clients index when client exists
+ await visit(OIDC_BASE_URL);
+ assert.equal(
+ currentRouteName(),
+ 'vault.cluster.access.oidc.clients.index',
+ 'redirects to clients index route when clients exist'
+ );
+ assert.dom('[data-test-tab="clients"]').hasClass('active', 'clients tab is active');
+ assert
+ .dom('[data-test-oidc-client-linked-block]')
+ .hasTextContaining('client-with-default-key', 'displays linked block for client');
+
+ // navigate to keys
+ await click('[data-test-tab="keys"]');
+ assert.dom('[data-test-tab="keys"]').hasClass('active', 'keys tab is active');
+ assert.equal(currentRouteName(), 'vault.cluster.access.oidc.keys.index');
+ assert
+ .dom('[data-test-oidc-key-linked-block="default"]')
+ .hasText('default', 'index page lists default key');
+
+ // navigate to default key details from pop-up menu
+ await click('[data-test-popup-menu-trigger]');
+ await click('[data-test-oidc-key-menu-link="details"]');
+ assert.dom(SELECTORS.keyDeleteButton).isDisabled('delete button is disabled for default key');
+ await click(SELECTORS.keyEditButton);
+ assert.equal(
+ currentRouteName(),
+ 'vault.cluster.access.oidc.keys.key.edit',
+ 'navigates to edit from key details'
+ );
+ await click(SELECTORS.keyCancelButton);
+ assert.equal(
+ currentRouteName(),
+ 'vault.cluster.access.oidc.keys.key.details',
+ 'key edit form navigates back to details on cancel'
+ );
+ await click(SELECTORS.keyClientsTab);
+ assert
+ .dom('[data-test-oidc-client-linked-block="client-with-default-key"]')
+ .exists('lists correct app with default');
+
+ // create a new key
+ await click('[data-test-breadcrumb-link="oidc-keys"]');
+ assert.equal(
+ currentRouteName(),
+ 'vault.cluster.access.oidc.keys.index',
+ 'keys breadcrumb navigates back to list view'
+ );
+ await click('[data-test-oidc-key-create]');
+ assert.equal(currentRouteName(), 'vault.cluster.access.oidc.keys.create', 'navigates to key create form');
+ await fillIn('[data-test-input="name"]', 'test-key');
+ await click(SELECTORS.keySaveButton);
+ assert.equal(
+ currentRouteName(),
+ 'vault.cluster.access.oidc.keys.key.details',
+ 'navigates to key details after save'
+ );
+
+ // create client with test-key
+ await visit(OIDC_BASE_URL + '/clients');
+ await click('[data-test-oidc-client-create]');
+ await fillIn('[data-test-input="name"]', 'client-with-test-key');
+ await click('[data-test-toggle-group="More options"]');
+ await click('[data-test-component="search-select"] [data-test-icon="trash"]');
+ await clickTrigger('#key');
+ await selectChoose('[data-test-component="search-select"]#key', 'test-key');
+ await click(SELECTORS.clientSaveButton);
+
+ // edit key and limit applications
+ await visit(OIDC_BASE_URL + '/keys');
+ await click('[data-test-oidc-key-linked-block="test-key"] [data-test-popup-menu-trigger]');
+ await click('[data-test-oidc-key-menu-link="edit"]');
+ assert.equal(
+ currentRouteName(),
+ 'vault.cluster.access.oidc.keys.key.edit',
+ 'key linked block popup menu navigates to edit'
+ );
+ await click('label[for=limited]');
+ await clickTrigger();
+ assert.equal(searchSelect.options.length, 1, 'dropdown has only application that uses this key');
+ assert
+ .dom('.ember-power-select-option')
+ .hasTextContaining('client-with-test-key', 'dropdown renders correct application');
+ await searchSelect.options.objectAt(0).click();
+ await click(SELECTORS.keySaveButton);
+ assert.equal(
+ flashMessage.latestMessage,
+ 'Successfully updated the key test-key.',
+ 'renders success flash upon key updating'
+ );
+ assert.equal(
+ currentRouteName(),
+ 'vault.cluster.access.oidc.keys.key.details',
+ 'navigates back to details on update'
+ );
+ await click(SELECTORS.keyClientsTab);
+ assert
+ .dom('[data-test-oidc-client-linked-block="client-with-test-key"]')
+ .exists('lists client-with-test-key');
+ assert.equal(findAll('[data-test-oidc-client-linked-block]').length, 1, 'it lists only one client');
+
+ // edit back to allow all
+ await click(SELECTORS.keyDetailsTab);
+ await click(SELECTORS.keyEditButton);
+ await click('label[for=allow-all]');
+ await click(SELECTORS.keySaveButton);
+ await click(SELECTORS.keyClientsTab);
+ assert.notEqual(
+ findAll('[data-test-oidc-client-linked-block]').length,
+ 1,
+ 'more than one client appears in key applications tab'
+ );
+
+ //* clean up test state
+ await clearRecord(this.store, 'oidc/client', 'client-with-test-key');
+ await clearRecord(this.store, 'oidc/client', 'client-with-default-key');
+ await clearRecord(this.store, 'oidc/key', 'test-key');
+ });
+
+ test('it creates, rotates and deletes a key', async function (assert) {
+ assert.expect(10);
+ // mock client list so OIDC url does not redirect to landing page
+ this.server.get('/identity/oidc/client', () => overrideMirageResponse(null, CLIENT_LIST_RESPONSE));
+ this.server.post('/identity/oidc/key/test-key/rotate', (schema, req) => {
+ const json = JSON.parse(req.requestBody);
+ assert.equal(json.verification_ttl, 86400, 'request made with correct args to accurate endpoint');
+ });
+
+ //* clear out test state
+ await clearRecord(this.store, 'oidc/key', 'test-key');
+
+ // create a new key
+ await visit(OIDC_BASE_URL + '/keys/create');
+ await fillIn('[data-test-input="name"]', 'test-key');
+ // toggle ttls to false, testing it sets correct default duration
+ await click('[data-test-input="rotationPeriod"]');
+ await click('[data-test-input="verificationTtl"]');
+ assert.dom('input#limited').isDisabled('limiting access radio button is disabled on create');
+ assert.dom('label[for=limited]').hasClass('is-disabled', 'limited radio button label has disabled class');
+ await click(SELECTORS.keySaveButton);
+ assert.equal(
+ flashMessage.latestMessage,
+ 'Successfully created the key test-key.',
+ 'renders success flash upon key creation'
+ );
+
+ // assert default values in details view are correct
+ assert.dom('[data-test-value-div="Algorithm"]').hasText('RS256', 'defaults to RS526 algorithm');
+ assert
+ .dom('[data-test-value-div="Rotation period"]')
+ .hasText('1 day', 'when toggled off rotation period defaults to 1 day');
+ assert
+ .dom('[data-test-value-div="Verification TTL"]')
+ .hasText('1 day', 'when toggled off verification ttl defaults to 1 day');
+
+ // rotate key
+ await click(SELECTORS.keyDetailsTab);
+ await click(SELECTORS.keyRotateButton);
+ await click(SELECTORS.confirmActionButton);
+ assert.equal(
+ flashMessage.latestMessage,
+ 'Success: test-key connection was rotated.',
+ 'renders success flash upon key rotation'
+ );
+ // delete
+ await click(SELECTORS.keyDeleteButton);
+ await click(SELECTORS.confirmActionButton);
+ assert.equal(
+ flashMessage.latestMessage,
+ 'Key deleted successfully',
+ 'success flash message renders after deleting key'
+ );
+ assert.equal(
+ currentRouteName(),
+ 'vault.cluster.access.oidc.keys.index',
+ 'navigates back to list view after delete'
+ );
+ });
+
+ test('it renders client details and providers', async function (assert) {
+ assert.expect(8);
+ this.server.get('/identity/oidc/client', () => overrideMirageResponse(null, CLIENT_LIST_RESPONSE));
+ this.server.get('/identity/oidc/client/test-app', () =>
+ overrideMirageResponse(null, CLIENT_DATA_RESPONSE)
+ );
+ await visit(OIDC_BASE_URL);
+ await click('[data-test-oidc-client-linked-block]');
+ assert.dom('[data-test-oidc-client-header]').hasText('test-app', 'renders application name as title');
+ assert.dom(SELECTORS.clientDetailsTab).hasClass('active', 'details tab is active');
+ assert.dom(SELECTORS.clientDeleteButton).exists('toolbar renders delete option');
+ assert.dom(SELECTORS.clientEditButton).exists('toolbar renders edit button');
+ assert.equal(findAll('[data-test-component="info-table-row"]').length, 9, 'renders all info rows');
+
+ await click(SELECTORS.clientProvidersTab);
+ assert.equal(
+ currentRouteName(),
+ 'vault.cluster.access.oidc.clients.client.providers',
+ 'navigates to client providers route'
+ );
+ assert.dom(SELECTORS.clientProvidersTab).hasClass('active', 'providers tab is active');
+ assert.dom('[data-test-oidc-provider-linked-block="default"]').exists('lists default provider');
+ });
+
+ test('it hides delete and edit client when no permission', async function (assert) {
+ assert.expect(5);
+ this.server.get('/identity/oidc/client', () => overrideMirageResponse(null, CLIENT_LIST_RESPONSE));
+ this.server.get('/identity/oidc/client/test-app', () =>
+ overrideMirageResponse(null, CLIENT_DATA_RESPONSE)
+ );
+ this.server.post('/sys/capabilities-self', () =>
+ overrideCapabilities(OIDC_BASE_URL + '/client/test-app', ['read'])
+ );
+
+ await visit(OIDC_BASE_URL);
+ await click('[data-test-oidc-client-linked-block]');
+ assert.dom('[data-test-oidc-client-header]').hasText('test-app', 'renders application name as title');
+ assert.dom(SELECTORS.clientDetailsTab).hasClass('active', 'details tab is active');
+ assert.dom(SELECTORS.clientDeleteButton).doesNotExist('delete option is hidden');
+ assert.dom(SELECTORS.clientEditButton).doesNotExist('edit button is hidden');
+ assert.equal(findAll('[data-test-component="info-table-row"]').length, 9, 'renders all info rows');
+ });
+
+ test('it hides delete and edit key when no permission', async function (assert) {
+ assert.expect(4);
+ this.server.get('/identity/oidc/keys', () => overrideMirageResponse(null, { keys: ['test-key'] }));
+ this.server.get('/identity/oidc/key/test-key', () =>
+ overrideMirageResponse(null, {
+ algorithm: 'RS256',
+ allowed_client_ids: ['*'],
+ rotation_period: 86400,
+ verification_ttl: 86400,
+ })
+ );
+ this.server.post('/sys/capabilities-self', () =>
+ overrideCapabilities(OIDC_BASE_URL + '/key/test-key', ['read'])
+ );
+
+ await visit(OIDC_BASE_URL + '/keys');
+ await click('[data-test-oidc-key-linked-block]');
+ assert.dom(SELECTORS.keyDetailsTab).hasClass('active', 'details tab is active');
+ assert.dom(SELECTORS.keyDeleteButton).doesNotExist('delete option is hidden');
+ assert.dom(SELECTORS.keyEditButton).doesNotExist('edit button is hidden');
+ assert.equal(findAll('[data-test-component="info-table-row"]').length, 4, 'renders all info rows');
+ });
+});
diff --git a/ui/tests/acceptance/oidc-config/providers-scopes-test.js b/ui/tests/acceptance/oidc-config/providers-scopes-test.js
new file mode 100644
index 000000000..f6945c763
--- /dev/null
+++ b/ui/tests/acceptance/oidc-config/providers-scopes-test.js
@@ -0,0 +1,395 @@
+import { module, test } from 'qunit';
+import { visit, currentURL, click, fillIn, findAll, currentRouteName } from '@ember/test-helpers';
+import { setupApplicationTest } from 'ember-qunit';
+import { setupMirage } from 'ember-cli-mirage/test-support';
+import ENV from 'vault/config/environment';
+import authPage from 'vault/tests/pages/auth';
+import logout from 'vault/tests/pages/logout';
+import { create } from 'ember-cli-page-object';
+import { clickTrigger, selectChoose } from 'ember-power-select/test-support/helpers';
+import ss from 'vault/tests/pages/components/search-select';
+import fm from 'vault/tests/pages/components/flash-message';
+import {
+ OIDC_BASE_URL,
+ SELECTORS,
+ CLIENT_LIST_RESPONSE,
+ SCOPE_LIST_RESPONSE,
+ SCOPE_DATA_RESPONSE,
+ PROVIDER_LIST_RESPONSE,
+ PROVIDER_DATA_RESPONSE,
+ clearRecord,
+ overrideCapabilities,
+ overrideMirageResponse,
+} from 'vault/tests/helpers/oidc-config';
+const searchSelect = create(ss);
+const flashMessage = create(fm);
+
+// OIDC_BASE_URL = '/vault/access/oidc'
+
+module('Acceptance | oidc-config providers and scopes', function (hooks) {
+ setupApplicationTest(hooks);
+ setupMirage(hooks);
+
+ hooks.before(function () {
+ ENV['ember-cli-mirage'].handler = 'oidcConfig';
+ });
+
+ hooks.beforeEach(async function () {
+ this.store = await this.owner.lookup('service:store');
+ // mock client list so OIDC BASE URL does not redirect to landing call-to-action image
+ this.server.get('/identity/oidc/client', () => overrideMirageResponse(null, CLIENT_LIST_RESPONSE));
+ return authPage.login();
+ });
+
+ hooks.afterEach(function () {
+ return logout.visit();
+ });
+
+ hooks.after(function () {
+ ENV['ember-cli-mirage'].handler = null;
+ });
+
+ // LIST SCOPES EMPTY
+ test('it navigates to scopes list view and renders empty state when no scopes are configured', async function (assert) {
+ assert.expect(4);
+ this.server.get('/identity/oidc/scope', () => overrideMirageResponse(404));
+ await visit(OIDC_BASE_URL);
+ await click('[data-test-tab="scopes"]');
+ assert.equal(currentURL(), '/vault/access/oidc/scopes');
+ assert.dom('[data-test-tab="scopes"]').hasClass('active', 'scopes tab is active');
+ assert
+ .dom(SELECTORS.scopeEmptyState)
+ .hasText(
+ `No scopes yet Use scope to define identity information about the authenticated user. Learn more. Create scope`,
+ 'renders empty state no scopes are configured'
+ );
+ assert
+ .dom(SELECTORS.scopeCreateButtonEmptyState)
+ .hasAttribute('href', '/ui/vault/access/oidc/scopes/create', 'empty state renders create scope link');
+ });
+
+ // LIST SCOPE EXIST
+ test('it renders scope list when scopes exist', async function (assert) {
+ assert.expect(11);
+ this.server.get('/identity/oidc/scope', () => overrideMirageResponse(null, SCOPE_LIST_RESPONSE));
+ this.server.get('/identity/oidc/scope/test-scope', () =>
+ overrideMirageResponse(null, SCOPE_DATA_RESPONSE)
+ );
+ await visit(OIDC_BASE_URL + '/scopes');
+ assert.equal(
+ currentRouteName(),
+ 'vault.cluster.access.oidc.scopes.index',
+ 'redirects to scopes index route when scopes exist'
+ );
+ assert
+ .dom('[data-test-oidc-scope-linked-block="test-scope"]')
+ .exists('displays linked block for test scope');
+
+ // navigates to/from create, edit, detail views from list view
+ await click(SELECTORS.scopeCreateButton);
+ assert.equal(
+ currentRouteName(),
+ 'vault.cluster.access.oidc.scopes.create',
+ 'scope index toolbar navigates to create form'
+ );
+ await click(SELECTORS.scopeCancelButton);
+ assert.equal(
+ currentRouteName(),
+ 'vault.cluster.access.oidc.scopes.index',
+ 'create form navigates back to index on cancel'
+ );
+
+ await click('[data-test-popup-menu-trigger]');
+ await click('[data-test-oidc-scope-menu-link="edit"]');
+ assert.equal(
+ currentRouteName(),
+ 'vault.cluster.access.oidc.scopes.scope.edit',
+ 'linked block popup menu navigates to edit'
+ );
+ await click(SELECTORS.scopeCancelButton);
+ assert.equal(
+ currentRouteName(),
+ 'vault.cluster.access.oidc.scopes.scope.details',
+ 'scope edit form navigates back to details on cancel'
+ );
+
+ // navigate to details from index page
+ await click('[data-test-breadcrumb-link="oidc-scopes"]');
+ await click('[data-test-popup-menu-trigger]');
+ await click('[data-test-oidc-scope-menu-link="details"]');
+ assert.equal(
+ currentRouteName(),
+ 'vault.cluster.access.oidc.scopes.scope.details',
+ 'popup menu navigates to details'
+ );
+ // check that details tab has all the information
+ assert.dom(SELECTORS.scopeDetailsTab).hasClass('active', 'details tab is active');
+ assert.dom(SELECTORS.scopeDeleteButton).exists('toolbar renders delete option');
+ assert.dom(SELECTORS.scopeEditButton).exists('toolbar renders edit button');
+ assert.equal(findAll('[data-test-component="info-table-row"]').length, 2, 'renders all info rows');
+ });
+
+ // ERROR DELETING SCOPE
+ test('it throws error when trying to delete when scope is currently being associated with any provider', async function (assert) {
+ assert.expect(3);
+ this.server.get('/identity/oidc/scope', () => overrideMirageResponse(null, SCOPE_LIST_RESPONSE));
+ this.server.get('/identity/oidc/scope/test-scope', () =>
+ overrideMirageResponse(null, SCOPE_DATA_RESPONSE)
+ );
+ this.server.get('/identity/oidc/provider', () => overrideMirageResponse(null, PROVIDER_LIST_RESPONSE));
+ this.server.get('/identity/oidc/provider/test-provider', () => {
+ overrideMirageResponse(null, PROVIDER_DATA_RESPONSE);
+ });
+ // throw error when trying to delete test-scope since it is associated to test-provider
+ this.server.delete(
+ '/identity/oidc/scope/test-scope',
+ () => ({
+ errors: [
+ 'unable to delete scope "test-scope" because it is currently referenced by these providers: test-provider',
+ ],
+ }),
+ 400
+ );
+ await visit(OIDC_BASE_URL + '/scopes');
+ await click('[data-test-oidc-scope-linked-block="test-scope"]');
+ assert.dom('[data-test-oidc-scope-header]').hasText('test-scope', 'renders scope name');
+ assert.dom(SELECTORS.scopeDetailsTab).hasClass('active', 'details tab is active');
+
+ // try to delete scope
+ await click(SELECTORS.scopeDeleteButton);
+ await click(SELECTORS.confirmActionButton);
+ assert.equal(
+ flashMessage.latestMessage,
+ 'unable to delete scope "test-scope" because it is currently referenced by these providers: test-provider',
+ 'renders error flash upon scope deletion'
+ );
+ });
+
+ // CRUD SCOPE + CRUD PROVIDER
+ test('it creates a scope, then adds it to a new a provider', async function (assert) {
+ assert.expect(27);
+
+ //* clear out test state
+ await clearRecord(this.store, 'oidc/scope', 'test-scope');
+ await clearRecord(this.store, 'oidc/provider', 'test-provider');
+
+ // create a new scope
+ await visit(OIDC_BASE_URL + '/scopes/create');
+ assert.equal(currentRouteName(), 'vault.cluster.access.oidc.scopes.create', 'navigates to create form');
+ await fillIn('[data-test-input="name"]', 'test-scope');
+ await fillIn('[data-test-input="description"]', 'this is a test');
+ await fillIn('[data-test-component="code-mirror-modifier"] textarea', SCOPE_DATA_RESPONSE.template);
+ await click(SELECTORS.scopeSaveButton);
+ assert.equal(
+ flashMessage.latestMessage,
+ 'Successfully created the scope test-scope.',
+ 'renders success flash upon scope creation'
+ );
+ assert.equal(
+ currentRouteName(),
+ 'vault.cluster.access.oidc.scopes.scope.details',
+ 'navigates to scope detail view after save'
+ );
+ assert.dom(SELECTORS.scopeDetailsTab).hasClass('active', 'scope details tab is active');
+ assert.dom('[data-test-value-div="Name"]').hasText('test-scope', 'has correct created name');
+ assert
+ .dom('[data-test-value-div="Description"]')
+ .hasText('this is a test', 'has correct created description');
+
+ // edit scope
+ await click(SELECTORS.scopeEditButton);
+ assert.equal(
+ currentRouteName(),
+ 'vault.cluster.access.oidc.scopes.scope.edit',
+ 'navigates to edit page from details'
+ );
+ await fillIn('[data-test-input="description"]', 'this is an edit test');
+ await click(SELECTORS.scopeSaveButton);
+ assert.equal(
+ flashMessage.latestMessage,
+ 'Successfully updated the scope test-scope.',
+ 'renders success flash upon scope updating'
+ );
+ assert.equal(
+ currentRouteName(),
+ 'vault.cluster.access.oidc.scopes.scope.details',
+ 'navigates back to scope details on update'
+ );
+ assert
+ .dom('[data-test-value-div="Description"]')
+ .hasText('this is an edit test', 'has correct edited description');
+
+ // create a provider using test-scope
+ await click('[data-test-breadcrumb-link="oidc-scopes"]');
+ await click('[data-test-tab="providers"]');
+ assert.dom('[data-test-tab="providers"]').hasClass('active', 'providers tab is active');
+ await click('[data-test-oidc-provider-create]');
+ assert.equal(
+ currentRouteName(),
+ 'vault.cluster.access.oidc.providers.create',
+ 'navigates to provider create form'
+ );
+ await fillIn('[data-test-input="name"]', 'test-provider');
+ await clickTrigger('#scopesSupported');
+ await selectChoose('#scopesSupported', 'test-scope');
+ await click(SELECTORS.providerSaveButton);
+ assert.equal(
+ flashMessage.latestMessage,
+ 'Successfully created the OIDC provider test-provider.',
+ 'renders success flash upon provider creation'
+ );
+ assert.equal(
+ currentRouteName(),
+ 'vault.cluster.access.oidc.providers.provider.details',
+ 'navigates to provider detail view after save'
+ );
+
+ // assert default values in details view are correct
+ assert.dom('[data-test-value-div="Issuer URL"]').hasTextContaining('http://', 'issuer includes scheme');
+ assert
+ .dom('[data-test-value-div="Issuer URL"]')
+ .hasTextContaining('identity/oidc/provider/test', 'issuer path populates correctly');
+ assert
+ .dom('[data-test-value-div="Scopes"] a')
+ .hasAttribute('href', '/ui/vault/access/oidc/scopes/test-scope/details', 'lists scopes as links');
+
+ // check provider's application list view
+ await click(SELECTORS.providerClientsTab);
+ assert.equal(
+ findAll('[data-test-oidc-client-linked-block]').length,
+ 2,
+ 'all applications appear in provider applications tab'
+ );
+
+ // edit and limit applications
+ await click(SELECTORS.providerDetailsTab);
+ await click(SELECTORS.providerEditButton);
+ assert.equal(
+ currentRouteName(),
+ 'vault.cluster.access.oidc.providers.provider.edit',
+ 'navigates to provider edit page from details'
+ );
+ await click('label[for=limited]');
+ await click('[data-test-component="search-select"]#allowedClientIds .ember-basic-dropdown-trigger');
+ await fillIn('.ember-power-select-search input', 'test-app');
+ await searchSelect.options.objectAt(0).click();
+ await click(SELECTORS.providerSaveButton);
+ assert.equal(
+ flashMessage.latestMessage,
+ 'Successfully updated the OIDC provider test-provider.',
+ 'renders success flash upon provider updating'
+ );
+ assert.equal(
+ currentRouteName(),
+ 'vault.cluster.access.oidc.providers.provider.details',
+ 'navigates back to provider details after updating'
+ );
+ await click(SELECTORS.providerClientsTab);
+ assert
+ .dom('[data-test-oidc-client-linked-block]')
+ .hasTextContaining('test-app', 'list of applications is just test-app');
+
+ // edit back to allow all
+ await click(SELECTORS.providerDetailsTab);
+ await click(SELECTORS.providerEditButton);
+ await click('label[for=allow-all]');
+ await click(SELECTORS.providerSaveButton);
+ await click(SELECTORS.providerClientsTab);
+ assert.equal(
+ findAll('[data-test-oidc-client-linked-block]').length,
+ 2,
+ 'all applications appear in provider applications tab'
+ );
+
+ // delete
+ await click(SELECTORS.providerDetailsTab);
+ await click(SELECTORS.providerDeleteButton);
+ await click(SELECTORS.confirmActionButton);
+ assert.equal(
+ flashMessage.latestMessage,
+ 'Provider deleted successfully',
+ 'success flash message renders after deleting provider'
+ );
+ assert.equal(
+ currentRouteName(),
+ 'vault.cluster.access.oidc.providers.index',
+ 'navigates back to list view after delete'
+ );
+
+ // delete scope
+ await visit(OIDC_BASE_URL + '/scopes/test-scope/details');
+ await click(SELECTORS.scopeDeleteButton);
+ await click(SELECTORS.confirmActionButton);
+ assert.equal(
+ flashMessage.latestMessage,
+ 'Scope deleted successfully',
+ 'renders success flash upon deleting scope'
+ );
+ assert.equal(
+ currentRouteName(),
+ 'vault.cluster.access.oidc.scopes.index',
+ 'navigates back to list view after delete'
+ );
+ });
+
+ // LIST PROVIDERS
+ test('it lists default provider and navigates to details', async function (assert) {
+ assert.expect(7);
+ await visit(OIDC_BASE_URL);
+ await click('[data-test-tab="providers"]');
+ assert.dom('[data-test-tab="providers"]').hasClass('active', 'providers tab is active');
+ assert.equal(currentURL(), '/vault/access/oidc/providers');
+ assert
+ .dom('[data-test-oidc-provider-linked-block="default"]')
+ .exists('index page lists default provider');
+ await click('[data-test-popup-menu-trigger]');
+
+ await click('[data-test-oidc-provider-menu-link="edit"]');
+ assert.equal(
+ currentRouteName(),
+ 'vault.cluster.access.oidc.providers.provider.edit',
+ 'provider linked block popup menu navigates to edit'
+ );
+ await click(SELECTORS.providerCancelButton);
+ assert.equal(
+ currentRouteName(),
+ 'vault.cluster.access.oidc.providers.provider.details',
+ 'provider edit form navigates back to details on cancel'
+ );
+
+ // navigate to details from index page
+ await click('[data-test-breadcrumb-link="oidc-providers"]');
+ assert.equal(
+ currentRouteName(),
+ 'vault.cluster.access.oidc.providers.index',
+ 'providers breadcrumb navigates back to list view'
+ );
+ await click('[data-test-oidc-provider-linked-block="default"] [data-test-popup-menu-trigger]');
+ await click('[data-test-oidc-provider-menu-link="details"]');
+ assert.dom(SELECTORS.providerDeleteButton).isDisabled('delete button is disabled for default provider');
+ });
+
+ // PROVIDER DELETE + EDIT PERMISSIONS
+ test('it hides delete and edit for a provider when no permission', async function (assert) {
+ assert.expect(3);
+ this.server.get('/identity/oidc/providers', () =>
+ overrideMirageResponse(null, { providers: ['test-provider'] })
+ );
+ this.server.get('/identity/oidc/provider/test-provider', () =>
+ overrideMirageResponse(null, {
+ allowed_client_ids: ['*'],
+ issuer: 'http://127.0.0.1:8200/v1/identity/oidc/provider/test-provider',
+ scopes_supported: ['test-scope'],
+ })
+ );
+ this.server.post('/sys/capabilities-self', () =>
+ overrideCapabilities(OIDC_BASE_URL + '/provider/test-provider', ['read'])
+ );
+
+ await visit(OIDC_BASE_URL + '/providers');
+ await click('[data-test-oidc-provider-linked-block]');
+ assert.dom(SELECTORS.providerDetailsTab).hasClass('active', 'details tab is active');
+ assert.dom(SELECTORS.providerDeleteButton).doesNotExist('delete option is hidden');
+ assert.dom(SELECTORS.providerEditButton).doesNotExist('edit button is hidden');
+ });
+});
diff --git a/ui/tests/acceptance/oidc-provider-test.js b/ui/tests/acceptance/oidc-provider-test.js
index 2b3fdc211..a52fdb3b5 100644
--- a/ui/tests/acceptance/oidc-provider-test.js
+++ b/ui/tests/acceptance/oidc-provider-test.js
@@ -7,7 +7,7 @@ import authForm from 'vault/tests/pages/components/auth-form';
import enablePage from 'vault/tests/pages/settings/auth/enable';
import consoleClass from 'vault/tests/pages/components/console/ui-panel';
import { visit, settled, currentURL } from '@ember/test-helpers';
-
+import { clearRecord } from 'vault/tests/helpers/oidc-config';
const consoleComponent = create(consoleClass);
const authFormComponent = create(authForm);
@@ -66,7 +66,7 @@ const entityAlias = async function (entityId, accessor, groupId) {
return consoleComponent.lastLogOutput.includes('Success');
};
const setupWebapp = async function (redirect) {
- let webappName = `my-webapp-${new Date().getTime()}`;
+ let webappName = 'my-webapp';
await consoleComponent.runCommands([
`write identity/oidc/client/${webappName} redirect_uris="${redirect}" assignments="my-assignment" key="sigkey" id_token_ttl="30m" access_token_ttl="1h"`,
`read -field=client_id identity/oidc/client/${webappName}`,
@@ -78,7 +78,7 @@ const setupWebapp = async function (redirect) {
return output;
};
const setupProvider = async function (clientId) {
- let providerName = `my-provider-${new Date().getTime()}`;
+ let providerName = `my-provider`;
await consoleComponent.runCommands(
`write identity/oidc/provider/${providerName} allowed_client_ids="${clientId}" scopes="user,groups"`
);
@@ -125,6 +125,7 @@ module('Acceptance | oidc provider', function (hooks) {
setupApplicationTest(hooks);
hooks.beforeEach(async function () {
+ this.store = await this.owner.lookup('service:store');
await logout.visit();
return authPage.login();
});
@@ -159,6 +160,10 @@ module('Acceptance | oidc provider', function (hooks) {
assert
.dom('[data-test-oidc-redirect]')
.hasTextContaining(`${callback}?code=`, 'Successful redirect to callback');
+
+ //* clean up test state
+ await clearRecord(this.store, 'oidc/client', 'my-webapp');
+ await clearRecord(this.store, 'oidc/provider', 'my-provider');
});
test('OIDC Provider redirects to auth if current token and prompt = login', async function (assert) {
@@ -180,6 +185,10 @@ module('Acceptance | oidc provider', function (hooks) {
assert
.dom('[data-test-oidc-redirect]')
.hasTextContaining(`${callback}?code=`, 'Successful redirect to callback');
+
+ //* clean up test state
+ await clearRecord(this.store, 'oidc/client', 'my-webapp');
+ await clearRecord(this.store, 'oidc/provider', 'my-provider');
});
test('OIDC Provider shows consent form when prompt = consent', async function (assert) {
@@ -197,5 +206,9 @@ module('Acceptance | oidc provider', function (hooks) {
'Does not redirect to auth because user is already logged in'
);
assert.dom('[data-test-consent-form]').exists('Consent form exists');
+
+ //* clean up test state
+ await clearRecord(this.store, 'oidc/client', 'my-webapp');
+ await clearRecord(this.store, 'oidc/provider', 'my-provider');
});
});
diff --git a/ui/tests/helpers/oidc-config.js b/ui/tests/helpers/oidc-config.js
new file mode 100644
index 000000000..5b6c94c6c
--- /dev/null
+++ b/ui/tests/helpers/oidc-config.js
@@ -0,0 +1,179 @@
+import { Response } from 'miragejs';
+
+export const OIDC_BASE_URL = `/vault/access/oidc`;
+
+export const SELECTORS = {
+ oidcHeader: '[data-test-oidc-header]',
+ oidcClientCreateButton: '[data-test-oidc-configure]',
+ oidcRouteTabs: '[data-test-oidc-tabs]',
+ oidcLandingImg: '[data-test-oidc-img]',
+ confirmActionButton: '[data-test-confirm-button="true"]',
+ inlineAlert: '[data-test-inline-alert]',
+ // client route
+ clientSaveButton: '[data-test-oidc-client-save]',
+ clientCancelButton: '[data-test-oidc-client-cancel]',
+ clientDeleteButton: '[data-test-oidc-client-delete] button',
+ clientEditButton: '[data-test-oidc-client-edit]',
+ clientDetailsTab: '[data-test-oidc-client-details]',
+ clientProvidersTab: '[data-test-oidc-client-providers]',
+
+ // assignment route
+ assignmentSaveButton: '[data-test-oidc-assignment-save]',
+ assignmentCreateButton: '[data-test-oidc-assignment-create]',
+ assignmentEditButton: '[data-test-oidc-assignment-edit]',
+ assignmentDeleteButton: '[data-test-oidc-assignment-delete] button',
+ assignmentCancelButton: '[data-test-oidc-assignment-cancel]',
+ assignmentDetailsTab: '[data-test-oidc-assignment-details]',
+
+ // scope routes
+ scopeSaveButton: '[data-test-oidc-scope-save]',
+ scopeCancelButton: '[data-test-oidc-scope-cancel]',
+ scopeDeleteButton: '[data-test-oidc-scope-delete] button',
+ scopeEditButton: '[data-test-oidc-scope-edit]',
+ scopeDetailsTab: '[data-test-oidc-scope-details]',
+ scopeEmptyState: '[data-test-oidc-scope-empty-state]',
+ scopeCreateButtonEmptyState: '[data-test-oidc-scope-create-empty-state]',
+ scopeCreateButton: '[data-test-oidc-scope-create]',
+
+ // key route
+ keySaveButton: '[data-test-oidc-key-save]',
+ keyCancelButton: '[data-test-oidc-key-cancel]',
+ keyDeleteButton: '[data-test-oidc-key-delete] button',
+ keyEditButton: '[data-test-oidc-key-edit]',
+ keyRotateButton: '[data-test-oidc-key-rotate] button',
+ keyDetailsTab: '[data-test-oidc-key-details]',
+ keyClientsTab: '[data-test-oidc-key-clients]',
+
+ // provider route
+ providerSaveButton: '[data-test-oidc-provider-save]',
+ providerCancelButton: '[data-test-oidc-provider-cancel]',
+ providerDeleteButton: '[data-test-oidc-provider-delete] button',
+ providerEditButton: '[data-test-oidc-provider-edit]',
+ providerDetailsTab: '[data-test-oidc-provider-details]',
+ providerClientsTab: '[data-test-oidc-provider-clients]',
+};
+
+export function overrideMirageResponse(httpStatus, data) {
+ if (httpStatus === 403) {
+ return new Response(
+ 403,
+ { 'Content-Type': 'application/json' },
+ JSON.stringify({ errors: ['permission denied'] })
+ );
+ }
+ if (httpStatus === 404) {
+ return new Response(404, { 'Content-Type': 'application/json' });
+ }
+ if (httpStatus === 200) {
+ return new Response(200, { 'Content-Type': 'application/json' }, JSON.stringify(data));
+ }
+ return {
+ request_id: crypto.randomUUID(),
+ lease_id: '',
+ renewable: false,
+ lease_duration: 0,
+ wrap_info: null,
+ warnings: null,
+ auth: null,
+ data: { ...data },
+ };
+}
+
+export function overrideCapabilities(requestPath, capabilitiesArray) {
+ // sample of capabilitiesArray: ['read', 'update']
+ return {
+ request_id: '40f7e44d-af5c-9b60-bd20-df72eb17e294',
+ lease_id: '',
+ renewable: false,
+ lease_duration: 0,
+ data: {
+ capabilities: capabilitiesArray,
+ [requestPath]: capabilitiesArray,
+ },
+ wrap_info: null,
+ warnings: null,
+ auth: null,
+ };
+}
+
+export async function clearRecord(store, modelType, id) {
+ await store
+ .findRecord(modelType, id)
+ .then((model) => {
+ deleteModelRecord(model);
+ })
+ .catch(() => {
+ // swallow error
+ });
+}
+
+const deleteModelRecord = async (model) => {
+ await model.destroyRecord();
+};
+
+// MOCK RESPONSES:
+
+export const CLIENT_LIST_RESPONSE = {
+ keys: ['test-app', 'app-1'],
+ key_info: {
+ 'test-app': {
+ assignments: ['allow_all'],
+ client_id: 'whaT7KB0C3iBH1l3rXhd5HPf0n6vXU0s',
+ client_secret: 'hvo_secret_nkJSTu2NVYqylXwFbFijsTxJHg4Ic4gqSJw7uOZ4FbSXcObngDkKoVsvyndrf2O8',
+ client_type: 'confidential',
+ id_token_ttl: 0,
+ key: 'default',
+ redirect_uris: [],
+ },
+ 'app-1': {
+ assignments: ['allow_all'],
+ client_id: 'HkmsTA4GG17j0Djy4EUAB2VAyzuLVewg',
+ client_secret: 'hvo_secret_g3f30MxAJWLXhhrCejbG4zY3O4LEHhEIO24aMy181AYKnfQtWTVV924ZmnlpUFUw',
+ client_type: 'confidential',
+ id_token_ttl: 0,
+ key: 'test-key',
+ redirect_uris: [],
+ },
+ },
+};
+
+export const CLIENT_DATA_RESPONSE = {
+ access_token_ttl: 0,
+ assignments: ['allow_all'],
+ client_id: 'whaT7KB0C3iBH1l3rXhd5HPf0n6vXU0s',
+ client_secret: 'hvo_secret_nkJSTu2NVYqylXwFbFijsTxJHg4Ic4gqSJw7uOZ4FbSXcObngDkKoVsvyndrf2O8',
+ client_type: 'confidential',
+ id_token_ttl: 0,
+ key: 'default',
+ redirect_uris: [],
+};
+
+export const ASSIGNMENT_LIST_RESPONSE = {
+ keys: ['allow_all', 'test-assignment'],
+};
+
+export const ASSIGNMENT_DATA_RESPONSE = {
+ group_ids: ['262ca5b9-7b69-0a84-446a-303dc7d778af'],
+ entity_ids: ['b6094ac6-baf4-6520-b05a-2bd9f07c66da'],
+};
+
+export const SCOPE_LIST_RESPONSE = {
+ keys: ['test-scope'],
+};
+
+export const SCOPE_DATA_RESPONSE = {
+ description: 'this is a test',
+ template: `{
+ "groups": {{identity.entity.groups.names}}
+ }`,
+};
+
+export const PROVIDER_LIST_RESPONSE = {
+ keys: ['test-provider'],
+};
+
+export const PROVIDER_DATA_RESPONSE = {
+ allowed_client_ids: ['*'],
+ issuer: '',
+ scopes_supported: ['test-scope'],
+};
diff --git a/ui/tests/integration/components/form-field-label-test.js b/ui/tests/integration/components/form-field-label-test.js
index d56b963a5..cb3a863d7 100644
--- a/ui/tests/integration/components/form-field-label-test.js
+++ b/ui/tests/integration/components/form-field-label-test.js
@@ -37,8 +37,8 @@ module('Integration | Component | form-field-label', function (hooks) {
assert.dom('[data-test-help-text]').hasText(this.helpText, 'Help text renders in tooltip');
assert.dom('.sub-text').hasText(this.subText, 'Sub text renders');
assert.dom('a').doesNotExist('docLink hidden when not provided');
- this.set('docLink', 'foo.com/bar');
+ this.set('docLink', '/doc/path');
assert.dom('.sub-text').includesText('See our documentation for help', 'Doc link text renders');
- assert.dom('a').hasAttribute('href', this.docLink, 'Doc link renders');
+ assert.dom('a').hasAttribute('href', 'https://www.vaultproject.io' + this.docLink, 'Doc link renders');
});
});
diff --git a/ui/tests/integration/components/info-table-item-array-test.js b/ui/tests/integration/components/info-table-item-array-test.js
index c6e6e0146..5c3770afa 100644
--- a/ui/tests/integration/components/info-table-item-array-test.js
+++ b/ui/tests/integration/components/info-table-item-array-test.js
@@ -32,7 +32,7 @@ module('Integration | Component | InfoTableItemArray', function (hooks) {
this.set('queryParam', 'role');
this.set('backend', 'transform');
this.set('wildcardLabel', 'role');
- this.set('viewAll', 'roles');
+ this.set('label', 'Roles');
run(() => {
this.owner.unregister('service:store');
this.owner.register('service:store', storeService);
@@ -58,13 +58,15 @@ module('Integration | Component | InfoTableItemArray', function (hooks) {
});
test('it renders links if isLink is true', async function (assert) {
- await render(hbs` `);
+ await render(hbs`
+
+ `);
assert.equal(
document.querySelectorAll('a > span').length,
DISPLAY_ARRAY.length,
@@ -75,15 +77,15 @@ module('Integration | Component | InfoTableItemArray', function (hooks) {
test('it renders a badge and view all if wildcard in display array && < 10', async function (assert) {
const displayArrayWithWildcard = ['role-1', 'role-2', 'role-3', 'r*'];
this.set('displayArrayWithWildcard', displayArrayWithWildcard);
- await render(hbs` `);
-
assert.equal(
document.querySelectorAll('a > span').length,
DISPLAY_ARRAY.length - 1,
@@ -92,6 +94,7 @@ module('Integration | Component | InfoTableItemArray', function (hooks) {
// 6 here comes from the six roles setup in the store service.
assert.dom('[data-test-count="6"]').exists('correctly counts with wildcard filter and shows the count');
assert.dom('[data-test-view-all="roles"]').exists({ count: 1 }, 'renders 1 view all roles');
+ assert.dom('[data-test-view-all="roles"]').hasText('View all roles.', 'renders correct view all text');
});
test('it renders a badge and view all if wildcard in display array && >= 10', async function (assert) {
@@ -109,18 +112,20 @@ module('Integration | Component | InfoTableItemArray', function (hooks) {
'role-10',
];
this.set('displayArrayWithWildcard', displayArrayWithWildcard);
- await render(hbs` `);
const numberCutOffTruncatedArray = displayArrayWithWildcard.length - 5;
assert.equal(document.querySelectorAll('a > span').length, 5, 'renders truncated array of five');
assert
.dom(`[data-test-and="${numberCutOffTruncatedArray}"]`)
.exists('correctly counts with wildcard filter and shows the count');
+ assert.dom('[data-test-view-all="roles"]').hasText('View all roles.', 'renders correct view all text');
});
});
diff --git a/ui/tests/integration/components/mfa-login-enforcement-header-test.js b/ui/tests/integration/components/mfa-login-enforcement-header-test.js
index e0867817f..6866c7802 100644
--- a/ui/tests/integration/components/mfa-login-enforcement-header-test.js
+++ b/ui/tests/integration/components/mfa-login-enforcement-header-test.js
@@ -3,6 +3,7 @@ import { setupRenderingTest } from 'ember-qunit';
import { render, click } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { setupMirage } from 'ember-cli-mirage/test-support';
+import { clickTrigger } from 'ember-power-select/test-support/helpers';
module('Integration | Component | mfa-login-enforcement-header', function (hooks) {
setupRenderingTest(hooks);
@@ -50,12 +51,11 @@ module('Integration | Component | mfa-login-enforcement-header', function (hooks
assert
.dom('[data-test-mleh-description]')
.includesText('An enforcement includes the authentication types', 'Description renders');
-
for (const option of ['new', 'existing', 'skip']) {
await click(`[data-test-mleh-radio="${option}"] input`);
assert.equal(this.value, option, 'Value is updated on radio select');
if (option === 'existing') {
- await click('.ember-basic-dropdown-trigger');
+ await clickTrigger();
await click('.ember-power-select-option');
}
}
diff --git a/ui/tests/integration/components/oidc/assignment-form-test.js b/ui/tests/integration/components/oidc/assignment-form-test.js
new file mode 100644
index 000000000..a4709db8a
--- /dev/null
+++ b/ui/tests/integration/components/oidc/assignment-form-test.js
@@ -0,0 +1,163 @@
+import { module, test } from 'qunit';
+import { setupRenderingTest } from 'ember-qunit';
+import { render, fillIn, click, findAll } from '@ember/test-helpers';
+import { hbs } from 'ember-cli-htmlbars';
+import { setupMirage } from 'ember-cli-mirage/test-support';
+import ENV from 'vault/config/environment';
+import { overrideMirageResponse } from 'vault/tests/helpers/oidc-config';
+
+module('Integration | Component | oidc/assignment-form', function (hooks) {
+ setupRenderingTest(hooks);
+ setupMirage(hooks);
+
+ hooks.before(function () {
+ ENV['ember-cli-mirage'].handler = 'oidcConfig';
+ });
+
+ hooks.after(function () {
+ ENV['ember-cli-mirage'].handler = null;
+ });
+
+ hooks.beforeEach(function () {
+ this.store = this.owner.lookup('service:store');
+ this.server.post('/sys/capabilities-self', () => {});
+ });
+
+ test('it should save new assignment', async function (assert) {
+ assert.expect(6);
+ this.model = this.store.createRecord('oidc/assignment');
+ this.server.post('/identity/oidc/assignment/test', (schema, req) => {
+ assert.ok(true, 'Request made to save assignment');
+ return JSON.parse(req.requestBody);
+ });
+ this.onSave = () => assert.ok(true, 'onSave callback fires on save success');
+
+ await render(hbs`
+
+ `);
+
+ assert.dom('[data-test-oidc-assignment-title]').hasText('Create assignment', 'Form title renders');
+ assert.dom('[data-test-oidc-assignment-save]').hasText('Create', 'Save button has correct label');
+ await click('[data-test-oidc-assignment-save]');
+ assert
+ .dom('[data-test-inline-alert]')
+ .hasText('Name is required.', 'Validation message is shown for name');
+ assert.equal(findAll('[data-test-inline-error-message]').length, 2, `there are two validations errors.`);
+ await fillIn('[data-test-input="name"]', 'test');
+ await click('[data-test-component="search-select"]#entities .ember-basic-dropdown-trigger');
+ await click('.ember-power-select-option');
+ await click('[data-test-oidc-assignment-save]');
+ });
+
+ test('it should populate fields with model data on edit view and update an assignment', async function (assert) {
+ assert.expect(6);
+
+ this.store.pushPayload('oidc/assignment', {
+ modelName: 'oidc/assignment',
+ name: 'test',
+ entity_ids: ['1234-12345'],
+ group_ids: ['abcdef-123'],
+ });
+ this.model = this.store.peekRecord('oidc/assignment', 'test');
+
+ await render(hbs`
+
+ `);
+
+ assert.dom('[data-test-oidc-assignment-title]').hasText('Edit assignment', 'Form title renders');
+ assert.dom('[data-test-oidc-assignment-save]').hasText('Update', 'Save button has correct label');
+ assert.dom('[data-test-input="name"]').isDisabled('Name input is disabled when editing');
+ assert.dom('[data-test-input="name"]').hasValue('test', 'Name input is populated with model value');
+ assert
+ .dom('[data-test-search-select="entities"] [data-test-smaller-id="true"]')
+ .hasText('1234-12345', 'entity id renders in selected option');
+ assert
+ .dom('[data-test-search-select="groups"] [data-test-smaller-id="true"]')
+ .hasText('abcdef-123', 'group id renders in selected option');
+ });
+
+ test('it should use fallback component on create if no permissions for entities or groups', async function (assert) {
+ assert.expect(2);
+ this.model = this.store.createRecord('oidc/assignment');
+ this.server.get('/identity/entity/id', () => overrideMirageResponse(403));
+ this.server.get('/identity/group/id', () => overrideMirageResponse(403));
+
+ await render(hbs`
+
+ `);
+
+ assert
+ .dom('[data-test-component="search-select"]#entities [data-test-component="string-list"]')
+ .exists('entities string list fallback component exists');
+ assert
+ .dom('[data-test-component="search-select"]#groups [data-test-component="string-list"]')
+ .exists('groups string list fallback component exists');
+ });
+
+ test('it should use fallback component on edit if no permissions for entities or groups', async function (assert) {
+ assert.expect(8);
+ this.store.pushPayload('oidc/assignment', {
+ modelName: 'oidc/assignment',
+ name: 'test',
+ entity_ids: ['1234-12345'],
+ group_ids: ['abcdef-123'],
+ });
+ this.model = this.store.peekRecord('oidc/assignment', 'test');
+ this.server.get('/identity/entity/id', () => overrideMirageResponse(403));
+ this.server.get('/identity/group/id', () => overrideMirageResponse(403));
+
+ await render(hbs`
+
+ `);
+
+ assert
+ .dom('[data-test-component="search-select"]#entities [data-test-component="string-list"]')
+ .exists('entities string list fallback component exists');
+ assert
+ .dom('[data-test-component="search-select"]#entities [data-test-string-list-input="0"]')
+ .hasValue('1234-12345', 'first row pre-populated with model entity');
+ assert
+ .dom(
+ '[data-test-component="search-select"]#entities [data-test-string-list-row="0"] [data-test-string-list-button="delete"]'
+ )
+ .exists('first row renders delete icon');
+ assert
+ .dom(
+ '[data-test-component="search-select"]#entities [data-test-string-list-row="1"] [data-test-string-list-button="add"]'
+ )
+ .exists('second row renders add icon');
+
+ assert
+ .dom('[data-test-component="search-select"]#groups [data-test-component="string-list"]')
+ .exists('groups string list fallback component exists');
+ assert
+ .dom('[data-test-component="search-select"]#groups [data-test-string-list-input="0"]')
+ .hasValue('abcdef-123', 'first row pre-populated with model group');
+ assert
+ .dom(
+ '[data-test-component="search-select"]#groups [data-test-string-list-row="0"] [data-test-string-list-button="delete"]'
+ )
+ .exists('first row renders delete icon');
+ assert
+ .dom(
+ '[data-test-component="search-select"]#groups [data-test-string-list-row="1"] [data-test-string-list-button="add"]'
+ )
+ .exists('second row renders add icon');
+ });
+});
diff --git a/ui/tests/integration/components/oidc/client-form-test.js b/ui/tests/integration/components/oidc/client-form-test.js
new file mode 100644
index 000000000..c37d88914
--- /dev/null
+++ b/ui/tests/integration/components/oidc/client-form-test.js
@@ -0,0 +1,251 @@
+import { module, test } from 'qunit';
+import { setupRenderingTest } from 'ember-qunit';
+import { render, fillIn, click, findAll } from '@ember/test-helpers';
+import { hbs } from 'ember-cli-htmlbars';
+import { create } from 'ember-cli-page-object';
+import { clickTrigger } from 'ember-power-select/test-support/helpers';
+import ss from 'vault/tests/pages/components/search-select';
+import { setupMirage } from 'ember-cli-mirage/test-support';
+import ENV from 'vault/config/environment';
+import {
+ OIDC_BASE_URL,
+ SELECTORS,
+ overrideMirageResponse,
+ overrideCapabilities,
+} from 'vault/tests/helpers/oidc-config';
+
+const searchSelect = create(ss);
+
+module('Integration | Component | oidc/client-form', function (hooks) {
+ setupRenderingTest(hooks);
+ setupMirage(hooks);
+
+ hooks.before(function () {
+ ENV['ember-cli-mirage'].handler = 'oidcConfig';
+ });
+
+ hooks.after(function () {
+ ENV['ember-cli-mirage'].handler = null;
+ });
+
+ hooks.beforeEach(function () {
+ this.store = this.owner.lookup('service:store');
+ this.server.post('/sys/capabilities-self', () => {});
+ this.server.get('/identity/oidc/key', () => {
+ return {
+ request_id: 'key-list-id',
+ data: {
+ keys: ['default'],
+ },
+ };
+ });
+ this.server.get('/identity/oidc/assignment', () => {
+ return {
+ request_id: 'assignment-list-id',
+ data: {
+ keys: ['allow_all', 'assignment-1'],
+ },
+ };
+ });
+ this.server.get('/identity/oidc/assignment/assignment-1', () => {
+ return {
+ request_id: 'assignment-1-id',
+ data: {
+ entity_ids: ['1234-12345'],
+ group_ids: ['abcdef-123'],
+ },
+ };
+ });
+ });
+
+ test('it should save new client', async function (assert) {
+ assert.expect(14);
+
+ this.server.post('/identity/oidc/client/test-app', (schema, req) => {
+ assert.ok(true, 'Request made to save client');
+ return JSON.parse(req.requestBody);
+ });
+ this.model = this.store.createRecord('oidc/client');
+ this.onSave = () => assert.ok(true, 'onSave callback fires on save success');
+
+ await render(hbs`
+
+ `);
+ await click('[data-test-toggle-group="More options"]');
+ assert
+ .dom('[data-test-oidc-client-title]')
+ .hasText('Create application', 'Form title renders correct text');
+ assert.dom(SELECTORS.clientSaveButton).hasText('Create', 'Save button has correct text');
+ assert.equal(findAll('[data-test-field]').length, 6, 'renders all attribute fields');
+ assert.dom('input#allow-all').isChecked('Allow all radio button selected by default');
+ assert.dom('[data-test-ttl-value="ID Token TTL"]').hasValue('1', 'ttl defaults to 24h');
+ assert.dom('[data-test-ttl-value="Access Token TTL"]').hasValue('1', 'ttl defaults to 24h');
+ assert.dom('[data-test-selected-option]').hasText('default', 'Search select has default key selected');
+
+ // check validation errors
+ await fillIn('[data-test-input="name"]', ' ');
+ await click('[data-test-selected-list-button="delete"]');
+ await click(SELECTORS.clientSaveButton);
+
+ let validationErrors = findAll(SELECTORS.inlineAlert);
+ assert
+ .dom(validationErrors[0])
+ .hasText('Name is required. Name cannot contain whitespace.', 'Validation messages are shown for name');
+ assert.dom(validationErrors[1]).hasText('Key is required.', 'Validation message is shown for key');
+ assert.dom(validationErrors[2]).hasText('There are 3 errors with this form.', 'Renders form error count');
+
+ // fill out form with valid inputs
+ await clickTrigger();
+ await fillIn('.ember-power-select-search input', 'default');
+ await searchSelect.options.objectAt(0).click();
+
+ await click('label[for=limited]');
+ assert
+ .dom('[data-test-search-select-with-modal]')
+ .exists('Limited radio button shows assignments search select');
+
+ await clickTrigger();
+ assert.dom('li.ember-power-select-option').hasText('assignment-1', 'dropdown renders assignments');
+ await fillIn('[data-test-input="name"]', 'test-app');
+ await click(SELECTORS.clientSaveButton);
+ });
+
+ test('it should update client', async function (assert) {
+ assert.expect(11);
+
+ this.server.post('/identity/oidc/client/test-app', (schema, req) => {
+ assert.ok(true, 'Request made to save client');
+ return JSON.parse(req.requestBody);
+ });
+
+ this.store.pushPayload('oidc/client', {
+ modelName: 'oidc/client',
+ name: 'test-app',
+ clientType: 'public',
+ });
+
+ this.model = this.store.peekRecord('oidc/client', 'test-app');
+ this.onSave = () => assert.ok(true, 'onSave callback fires on save success');
+
+ await render(hbs`
+
+ `);
+ await click('[data-test-toggle-group="More options"]');
+ assert.dom('[data-test-oidc-client-title]').hasText('Edit application', 'Title renders correct text');
+ assert.dom(SELECTORS.clientSaveButton).hasText('Update', 'Save button has correct text');
+ assert.dom('[data-test-input="name"]').isDisabled('Name input is disabled when editing');
+ assert.dom('[data-test-input="name"]').hasValue('test-app', 'Name input is populated with model value');
+ assert.dom('[data-test-input="key"]').isDisabled('Signing key input is disabled');
+ assert.dom('[data-test-input="key"]').hasValue('default', 'Key input populated with default');
+ assert.dom('[data-test-input="clientType"] input').isDisabled('client type input is disabled on edit');
+ assert
+ .dom('[data-test-input="clientType"] input#confidential')
+ .isChecked('Correct radio button is selected');
+ assert.dom('input#allow-all').isChecked('Allow all radio button is selected');
+ await click(SELECTORS.clientSaveButton);
+ });
+
+ test('it should rollback attributes or unload record on cancel', async function (assert) {
+ assert.expect(4);
+ this.model = this.store.createRecord('oidc/client');
+ this.onCancel = () => assert.ok(true, 'onCancel callback fires');
+
+ await render(hbs`
+
+ `);
+
+ await click(SELECTORS.clientCancelButton);
+ assert.true(this.model.isDestroyed, 'New model is unloaded on cancel');
+
+ this.store.pushPayload('oidc/client', {
+ modelName: 'oidc/client',
+ name: 'test-app',
+ assignments: ['allow_all'],
+ redirectUris: [],
+ });
+ this.model = this.store.peekRecord('oidc/client', 'test-app');
+
+ await render(hbs`
+
+ `);
+
+ await fillIn('[data-test-input="redirectUris"] [data-test-string-list-input="0"]', 'some-url.com');
+ await click('[data-test-string-list-button="add"]');
+ await click(SELECTORS.clientCancelButton);
+ assert.equal(this.model.redirectUris, undefined, 'Model attributes rolled back on cancel');
+ });
+
+ test('it should show create assignment modal', async function (assert) {
+ assert.expect(2);
+ this.model = this.store.createRecord('oidc/client');
+
+ await render(hbs`
+
+
+ `);
+ await click('label[for=limited]');
+ await clickTrigger();
+ await fillIn('.ember-power-select-search input', 'test-new');
+ await searchSelect.options.objectAt(0).click();
+ assert.dom('[data-test-modal-title]').hasText('Create new assignment', 'Create assignment modal renders');
+ await click(SELECTORS.assignmentCancelButton);
+ assert.dom('[data-test-modal-div]').doesNotExist('Modal disappears after clicking cancel');
+ });
+
+ test('it should render fallback for search select', async function (assert) {
+ assert.expect(1);
+ this.model = this.store.createRecord('oidc/client');
+ this.server.get('/identity/oidc/assignment', () => overrideMirageResponse(403));
+ await render(hbs`
+
+ `);
+
+ await click('label[for=limited]');
+ assert
+ .dom('[data-test-component="string-list"]')
+ .exists('Radio toggle shows assignments string-list input');
+ });
+
+ test('it should render error alerts when API returns an error', async function (assert) {
+ assert.expect(2);
+ this.model = this.store.createRecord('oidc/client');
+ this.server.post('/sys/capabilities-self', () => overrideCapabilities(OIDC_BASE_URL + '/clients'));
+ await render(hbs`
+
+ `);
+ await fillIn('[data-test-input="name"]', 'test-app');
+ await click(SELECTORS.clientSaveButton);
+ assert
+ .dom(SELECTORS.inlineAlert)
+ .hasText('There was an error submitting this form.', 'form error alert renders ');
+ assert.dom('[data-test-alert-banner="alert"]').exists('alert banner renders');
+ });
+});
diff --git a/ui/tests/integration/components/oidc/key-form-test.js b/ui/tests/integration/components/oidc/key-form-test.js
new file mode 100644
index 000000000..bf6945c3d
--- /dev/null
+++ b/ui/tests/integration/components/oidc/key-form-test.js
@@ -0,0 +1,204 @@
+import { module, test } from 'qunit';
+import { setupRenderingTest } from 'ember-qunit';
+import { render, fillIn, click, findAll } from '@ember/test-helpers';
+import { hbs } from 'ember-cli-htmlbars';
+import { setupMirage } from 'ember-cli-mirage/test-support';
+import ENV from 'vault/config/environment';
+import {
+ OIDC_BASE_URL,
+ CLIENT_LIST_RESPONSE,
+ SELECTORS,
+ overrideMirageResponse,
+ overrideCapabilities,
+} from 'vault/tests/helpers/oidc-config';
+
+module('Integration | Component | oidc/key-form', function (hooks) {
+ setupRenderingTest(hooks);
+ setupMirage(hooks);
+
+ hooks.before(function () {
+ ENV['ember-cli-mirage'].handler = 'oidcConfig';
+ });
+
+ hooks.after(function () {
+ ENV['ember-cli-mirage'].handler = null;
+ });
+
+ hooks.beforeEach(function () {
+ this.store = this.owner.lookup('service:store');
+ this.server.get('/identity/oidc/client', () => overrideMirageResponse(null, CLIENT_LIST_RESPONSE));
+ });
+
+ test('it should save new key', async function (assert) {
+ assert.expect(9);
+ this.server.post('/identity/oidc/key/test-key', (schema, req) => {
+ assert.ok(true, 'Request made to save key');
+ return JSON.parse(req.requestBody);
+ });
+ this.model = this.store.createRecord('oidc/key');
+ this.onSave = () => assert.ok(true, 'onSave callback fires on save success');
+ await render(hbs`
+
+ `);
+
+ assert.dom('[data-test-oidc-key-title]').hasText('Create key', 'Form title renders correct text');
+ assert.dom(SELECTORS.keySaveButton).hasText('Create', 'Save button has correct text');
+ assert.dom('[data-test-input="algorithm"]').hasValue('RS256', 'default algorithm is correct');
+ assert.equal(findAll('[data-test-field]').length, 4, 'renders all input fields');
+
+ // check validation errors
+ await fillIn('[data-test-input="name"]', ' ');
+ await click(SELECTORS.keySaveButton);
+
+ let validationErrors = findAll(SELECTORS.inlineAlert);
+ assert
+ .dom(validationErrors[0])
+ .hasText('Name is required. Name cannot contain whitespace.', 'Validation messages are shown for name');
+ assert.dom(validationErrors[1]).hasText('There are 2 errors with this form.', 'Renders form error count');
+
+ assert.dom('label[for=limited] input').isDisabled('limit radio button disabled on create');
+ await fillIn('[data-test-input="name"]', 'test-key');
+ await click(SELECTORS.keySaveButton);
+ });
+
+ test('it should update key and limit access to selected applications', async function (assert) {
+ assert.expect(12);
+
+ this.server.post('/identity/oidc/key/test-key', (schema, req) => {
+ assert.ok(true, 'Request made to update key');
+ return JSON.parse(req.requestBody);
+ });
+
+ this.store.pushPayload('oidc/key', {
+ modelName: 'oidc/key',
+ name: 'test-key',
+ allowed_client_ids: ['*'],
+ });
+
+ this.model = this.store.peekRecord('oidc/key', 'test-key');
+ this.onSave = () => assert.ok(true, 'onSave callback fires on update success');
+
+ await render(hbs`
+
+ `);
+
+ assert.dom('[data-test-oidc-key-title]').hasText('Edit key', 'Title renders correct text');
+ assert.dom(SELECTORS.keySaveButton).hasText('Update', 'Save button has correct text');
+ assert.dom('[data-test-input="name"]').isDisabled('Name input is disabled when editing');
+ assert.dom('[data-test-input="name"]').hasValue('test-key', 'Name input is populated with model value');
+ assert.dom('input#allow-all').isChecked('Allow all radio button is selected');
+
+ await click('label[for=limited]');
+ assert
+ .dom('[data-test-component="search-select"]#allowedClientIds')
+ .exists('Limited radio button shows clients search select');
+ await click('[data-test-component="search-select"]#allowedClientIds .ember-basic-dropdown-trigger');
+ assert.equal(findAll('li.ember-power-select-option').length, 1, 'dropdown only renders one option');
+ assert
+ .dom('li.ember-power-select-option')
+ .hasTextContaining('app-1', 'dropdown contains client that references key');
+ assert.dom('[data-test-smaller-id]').exists('renders smaller client id in dropdown');
+
+ await click('label[for=allow-all]');
+ assert
+ .dom('[data-test-component="search-select"]#allowedClientIds')
+ .doesNotExist('Allow all radio button hides search select');
+
+ await click(SELECTORS.keySaveButton);
+ });
+
+ test('it should rollback attributes or unload record on cancel', async function (assert) {
+ assert.expect(4);
+ this.model = this.store.createRecord('oidc/key');
+ this.onCancel = () => assert.ok(true, 'onCancel callback fires');
+
+ await render(hbs`
+
+ `);
+
+ await click(SELECTORS.keyCancelButton);
+ assert.true(this.model.isDestroyed, 'New model is unloaded on cancel');
+
+ this.store.pushPayload('oidc/key', {
+ modelName: 'oidc/key',
+ name: 'test-key',
+ allowed_client_ids: ['*'],
+ });
+
+ this.model = this.store.peekRecord('oidc/key', 'test-key');
+
+ await render(hbs`
+
+ `);
+
+ await click('label[for=limited]');
+ await click(SELECTORS.keyCancelButton);
+ assert.equal(this.model.allowed_client_ids, undefined, 'Model attributes rolled back on cancel');
+ });
+
+ test('it should render fallback for search select', async function (assert) {
+ assert.expect(1);
+
+ this.server.post('/identity/oidc/key/test-key', (schema, req) => {
+ assert.ok(true, 'Request made to update key');
+ return JSON.parse(req.requestBody);
+ });
+
+ this.store.pushPayload('oidc/key', {
+ modelName: 'oidc/key',
+ name: 'test-key',
+ allowed_client_ids: ['*'],
+ });
+
+ this.model = this.store.peekRecord('oidc/key', 'test-key');
+
+ this.server.get('/identity/oidc/client', () => overrideMirageResponse(403));
+ await render(hbs`
+
+ `);
+
+ await click('label[for=limited]');
+ assert
+ .dom('[data-test-component="search-select"]#allowedClientIds [data-test-component="string-list"]')
+ .exists('Radio toggle shows client string-list input');
+ });
+
+ test('it should render error alerts when API returns an error', async function (assert) {
+ assert.expect(2);
+ this.model = this.store.createRecord('oidc/key');
+ this.server.post('/sys/capabilities-self', () => overrideCapabilities(OIDC_BASE_URL + '/keys'));
+ await render(hbs`
+
+ `);
+ await fillIn('[data-test-input="name"]', 'test-app');
+ await click(SELECTORS.keySaveButton);
+ assert
+ .dom(SELECTORS.inlineAlert)
+ .hasText('There was an error submitting this form.', 'form error alert renders ');
+ assert.dom('[data-test-alert-banner="alert"]').exists('alert banner renders');
+ });
+});
diff --git a/ui/tests/integration/components/oidc/provider-form-test.js b/ui/tests/integration/components/oidc/provider-form-test.js
new file mode 100644
index 000000000..e7214dcc7
--- /dev/null
+++ b/ui/tests/integration/components/oidc/provider-form-test.js
@@ -0,0 +1,224 @@
+import { module, test } from 'qunit';
+import { setupRenderingTest } from 'ember-qunit';
+import { render, fillIn, click, findAll } from '@ember/test-helpers';
+import { hbs } from 'ember-cli-htmlbars';
+import { setupMirage } from 'ember-cli-mirage/test-support';
+import ENV from 'vault/config/environment';
+import {
+ SELECTORS,
+ OIDC_BASE_URL,
+ CLIENT_LIST_RESPONSE,
+ overrideMirageResponse,
+ overrideCapabilities,
+} from 'vault/tests/helpers/oidc-config';
+import parseURL from 'core/utils/parse-url';
+
+const ISSUER_URL = 'http://127.0.0.1:8200/v1/identity/oidc/provider/test-provider';
+
+module('Integration | Component | oidc/provider-form', function (hooks) {
+ setupRenderingTest(hooks);
+ setupMirage(hooks);
+
+ hooks.before(function () {
+ ENV['ember-cli-mirage'].handler = 'oidcConfig';
+ });
+
+ hooks.after(function () {
+ ENV['ember-cli-mirage'].handler = null;
+ });
+
+ hooks.beforeEach(function () {
+ this.store = this.owner.lookup('service:store');
+ this.server.get('/identity/oidc/scope', () => {
+ return {
+ request_id: 'scope-list-id',
+ lease_id: '',
+ renewable: false,
+ lease_duration: 0,
+ data: {
+ keys: ['test-scope'],
+ },
+ wrap_info: null,
+ warnings: null,
+ auth: null,
+ };
+ });
+ this.server.get('/identity/oidc/client', () => overrideMirageResponse(null, CLIENT_LIST_RESPONSE));
+ });
+
+ test('it should save new provider', async function (assert) {
+ assert.expect(13);
+ this.server.post('/identity/oidc/provider/test-provider', (schema, req) => {
+ assert.ok(true, 'Request made to save provider');
+ return JSON.parse(req.requestBody);
+ });
+ this.model = this.store.createRecord('oidc/provider');
+ this.onSave = () => assert.ok(true, 'onSave callback fires on save success');
+ await render(hbs`
+
+ `);
+
+ assert
+ .dom('[data-test-oidc-provider-title]')
+ .hasText('Create provider', 'Form title renders correct text');
+ assert.dom(SELECTORS.providerSaveButton).hasText('Create', 'Save button has correct text');
+ assert
+ .dom('[data-test-input="issuer"]')
+ .hasAttribute('placeholder', 'e.g. https://example.com:8200', 'issuer placeholder text is correct');
+ assert.equal(findAll('[data-test-field]').length, 3, 'renders all input fields');
+ await click('[data-test-component="search-select"]#scopesSupported .ember-basic-dropdown-trigger');
+ assert.dom('li.ember-power-select-option').hasText('test-scope', 'dropdown renders scopes');
+
+ // check validation errors
+ await fillIn('[data-test-input="name"]', ' ');
+ await click(SELECTORS.providerSaveButton);
+
+ let validationErrors = findAll(SELECTORS.inlineAlert);
+ assert
+ .dom(validationErrors[0])
+ .hasText('Name is required. Name cannot contain whitespace.', 'Validation messages are shown for name');
+ assert.dom(validationErrors[1]).hasText('There are 2 errors with this form.', 'Renders form error count');
+
+ await click('label[for=limited]');
+ assert
+ .dom('[data-test-component="search-select"]#allowedClientIds')
+ .exists('Limited radio button shows clients search select');
+ await click('[data-test-component="search-select"]#allowedClientIds .ember-basic-dropdown-trigger');
+ assert.dom('li.ember-power-select-option').hasTextContaining('test-app', 'dropdown renders client name');
+ assert.dom('[data-test-smaller-id]').exists('renders smaller client id in dropdown');
+
+ await click('label[for=allow-all]');
+ assert
+ .dom('[data-test-component="search-select"]#allowedClientIds')
+ .doesNotExist('Allow all radio button hides search select');
+
+ await fillIn('[data-test-input="name"]', 'test-provider');
+ await click(SELECTORS.providerSaveButton);
+ });
+
+ test('it should update provider', async function (assert) {
+ assert.expect(9);
+
+ this.server.post('/identity/oidc/provider/test-provider', (schema, req) => {
+ assert.ok(true, 'Request made to save provider');
+ return JSON.parse(req.requestBody);
+ });
+
+ this.store.pushPayload('oidc/provider', {
+ modelName: 'oidc/provider',
+ name: 'test-provider',
+ allowed_client_ids: ['*'],
+ issuer: ISSUER_URL,
+ scopes_supported: ['test-scope'],
+ });
+
+ this.model = this.store.peekRecord('oidc/provider', 'test-provider');
+ this.onSave = () => assert.ok(true, 'onSave callback fires on save success');
+
+ await render(hbs`
+
+ `);
+
+ assert.dom('[data-test-oidc-provider-title]').hasText('Edit provider', 'Title renders correct text');
+ assert.dom(SELECTORS.providerSaveButton).hasText('Update', 'Save button has correct text');
+ assert.dom('[data-test-input="name"]').isDisabled('Name input is disabled when editing');
+ assert
+ .dom('[data-test-input="name"]')
+ .hasValue('test-provider', 'Name input is populated with model value');
+ assert
+ .dom('[data-test-input="issuer"]')
+ .hasValue(parseURL(ISSUER_URL).origin, 'issuer value is just scheme://host:port portion of full URL');
+
+ assert.dom('[data-test-selected-option]').hasText('test-scope', 'model scope is selected');
+ assert.dom('input#allow-all').isChecked('Allow all radio button is selected');
+ await click(SELECTORS.providerSaveButton);
+ });
+
+ test('it should rollback attributes or unload record on cancel', async function (assert) {
+ assert.expect(4);
+ this.model = this.store.createRecord('oidc/provider');
+ this.onCancel = () => assert.ok(true, 'onCancel callback fires');
+
+ await render(hbs`
+
+ `);
+
+ await click(SELECTORS.providerCancelButton);
+ assert.true(this.model.isDestroyed, 'New model is unloaded on cancel');
+
+ this.store.pushPayload('oidc/provider', {
+ modelName: 'oidc/provider',
+ name: 'test-provider',
+ allowed_client_ids: ['*'],
+ issuer: ISSUER_URL,
+ scopes_supported: ['test-scope'],
+ });
+
+ this.model = this.store.peekRecord('oidc/provider', 'test-provider');
+
+ await render(hbs`
+
+ `);
+
+ await click('label[for=limited]');
+ await click(SELECTORS.providerCancelButton);
+ assert.equal(this.model.allowed_client_ids, undefined, 'Model attributes rolled back on cancel');
+ });
+
+ test('it should render fallback for search select', async function (assert) {
+ assert.expect(2);
+ this.model = this.store.createRecord('oidc/provider');
+ this.server.get('/identity/oidc/scope', () => overrideMirageResponse(403));
+ this.server.get('/identity/oidc/client', () => overrideMirageResponse(403));
+ await render(hbs`
+
+ `);
+
+ assert
+ .dom('[data-test-component="search-select"]#scopesSupported [data-test-component="string-list"]')
+ .exists('renders fall back for scopes search select');
+ await click('label[for=limited]');
+ assert
+ .dom('[data-test-component="search-select"]#allowedClientIds [data-test-component="string-list"]')
+ .exists('Radio toggle shows assignments string-list input');
+ });
+
+ test('it should render error alerts when API returns an error', async function (assert) {
+ assert.expect(2);
+ this.model = this.store.createRecord('oidc/provider');
+ this.server.post('/sys/capabilities-self', () => overrideCapabilities(OIDC_BASE_URL + '/providers'));
+ await render(hbs`
+
+ `);
+ await fillIn('[data-test-input="name"]', 'some-provider');
+ await click(SELECTORS.providerSaveButton);
+ assert
+ .dom(SELECTORS.inlineAlert)
+ .hasText('There was an error submitting this form.', 'form error alert renders ');
+ assert.dom('[data-test-alert-banner="alert"]').exists('alert banner renders');
+ });
+});
diff --git a/ui/tests/integration/components/oidc/scope-form-test.js b/ui/tests/integration/components/oidc/scope-form-test.js
new file mode 100644
index 000000000..83d0a266c
--- /dev/null
+++ b/ui/tests/integration/components/oidc/scope-form-test.js
@@ -0,0 +1,192 @@
+import { module, test } from 'qunit';
+import { setupRenderingTest } from 'ember-qunit';
+import { render, fillIn, click, findAll } from '@ember/test-helpers';
+import { hbs } from 'ember-cli-htmlbars';
+import { setupMirage } from 'ember-cli-mirage/test-support';
+import { SELECTORS, OIDC_BASE_URL, overrideCapabilities } from 'vault/tests/helpers/oidc-config';
+
+module('Integration | Component | oidc/scope-form', function (hooks) {
+ setupRenderingTest(hooks);
+ setupMirage(hooks);
+
+ hooks.beforeEach(function () {
+ this.store = this.owner.lookup('service:store');
+ });
+
+ test('it should save new scope', async function (assert) {
+ assert.expect(9);
+
+ this.server.post('/identity/oidc/scope/test', (schema, req) => {
+ assert.ok(true, 'Request made to save scope');
+ return JSON.parse(req.requestBody);
+ });
+
+ this.model = this.store.createRecord('oidc/scope');
+ this.onSave = () => assert.ok(true, 'onSave callback fires on save success');
+
+ await render(hbs`
+
+
+ `);
+
+ assert.dom('[data-test-oidc-scope-title]').hasText('Create scope', 'Form title renders');
+ assert.dom(SELECTORS.scopeSaveButton).hasText('Create', 'Save button has correct label');
+ await click(SELECTORS.scopeSaveButton);
+
+ // check validation errors
+ await click(SELECTORS.scopeSaveButton);
+
+ let validationErrors = findAll(SELECTORS.inlineAlert);
+ assert.dom(validationErrors[0]).hasText('Name is required.', 'Validation messages are shown for name');
+ assert.dom(validationErrors[1]).hasText('There is an error with this form.', 'Renders form error count');
+
+ assert
+ .dom('[data-test-inline-error-message]')
+ .hasText('Name is required.', 'Validation message is shown for name');
+ // json editor has test coverage so let's just confirm that it renders
+ assert
+ .dom('[data-test-input="template"] [data-test-component="json-editor-toolbar"]')
+ .exists('JsonEditor toolbar renders');
+ assert
+ .dom('[data-test-input="template"] [data-test-component="code-mirror-modifier"]')
+ .exists('Code mirror renders');
+
+ await fillIn('[data-test-input="name"]', 'test');
+ await fillIn('[data-test-input="description"]', 'this is a test');
+ await click(SELECTORS.scopeSaveButton);
+ });
+
+ test('it should update scope', async function (assert) {
+ assert.expect(9);
+
+ this.server.post('/identity/oidc/scope/test', (schema, req) => {
+ assert.ok(true, 'Request made to save scope');
+ return JSON.parse(req.requestBody);
+ });
+
+ this.store.pushPayload('oidc/scope', {
+ modelName: 'oidc/scope',
+ name: 'test',
+ description: 'this is a test',
+ });
+ this.model = this.store.peekRecord('oidc/scope', 'test');
+ this.onSave = () => assert.ok(true, 'onSave callback fires on save success');
+
+ await render(hbs`
+
+
+ `);
+
+ assert.dom('[data-test-oidc-scope-title]').hasText('Edit scope', 'Form title renders');
+ assert.dom(SELECTORS.scopeSaveButton).hasText('Update', 'Save button has correct label');
+ assert.dom('[data-test-input="name"]').isDisabled('Name input is disabled when editing');
+ assert.dom('[data-test-input="name"]').hasValue('test', 'Name input is populated with model value');
+ assert
+ .dom('[data-test-input="description"]')
+ .hasValue('this is a test', 'Description input is populated with model value');
+ // json editor has test coverage so let's just confirm that it renders
+ assert
+ .dom('[data-test-input="template"] [data-test-component="json-editor-toolbar"]')
+ .exists('JsonEditor toolbar renders');
+ assert
+ .dom('[data-test-input="template"] [data-test-component="code-mirror-modifier"]')
+ .exists('Code mirror renders');
+
+ await fillIn('[data-test-input="description"]', 'this is an edit test');
+ await click(SELECTORS.scopeSaveButton);
+ });
+
+ test('it should rollback attributes or unload record on cancel', async function (assert) {
+ assert.expect(4);
+
+ this.onCancel = () => assert.ok(true, 'onCancel callback fires');
+
+ this.model = this.store.createRecord('oidc/scope');
+
+ await render(hbs`
+
+
+ `);
+
+ await click(SELECTORS.scopeCancelButton);
+ assert.true(this.model.isDestroyed, 'New model is unloaded on cancel');
+
+ this.store.pushPayload('oidc/scope', {
+ modelName: 'oidc/scope',
+ name: 'test',
+ description: 'this is a test',
+ });
+ this.model = this.store.peekRecord('oidc/scope', 'test');
+
+ await render(hbs`
+
+
+ `);
+
+ await fillIn('[data-test-input="description"]', 'changed description attribute');
+ await click(SELECTORS.scopeCancelButton);
+ assert.equal(this.model.description, 'this is a test', 'Model attributes are rolled back on cancel');
+ });
+
+ test('it should show example template modal', async function (assert) {
+ assert.expect(6);
+
+ this.model = this.store.createRecord('oidc/scope');
+
+ await render(hbs`
+
+
+ `);
+
+ assert.dom('[data-test-modal-div]').doesNotHaveClass('is-active', 'Modal is hidden');
+ await click('[data-test-oidc-scope-example]');
+ assert.dom('[data-test-modal-div]').hasClass('is-active', 'Modal is shown');
+ assert.dom('[data-test-modal-title]').hasText('Scope template', 'Modal title renders');
+ assert
+ .dom('[data-test-modal-copy]')
+ .hasText('Example of a JSON template for scopes:', 'Modal copy renders');
+ assert.dom('.cm-string').hasText('"username"', 'Example template json renders');
+ await click('[data-test-close-modal]');
+ assert.dom('[data-test-modal-div]').doesNotHaveClass('is-active', 'Modal is hidden');
+ });
+
+ test('it should render error alerts when API returns an error', async function (assert) {
+ assert.expect(2);
+ this.model = this.store.createRecord('oidc/scope');
+ this.server.post('/sys/capabilities-self', () => overrideCapabilities(OIDC_BASE_URL + '/scopes'));
+ await render(hbs`
+
+
+ `);
+ await fillIn('[data-test-input="name"]', 'test-scope');
+ await click(SELECTORS.scopeSaveButton);
+ assert
+ .dom(SELECTORS.inlineAlert)
+ .hasText('There was an error submitting this form.', 'form error alert renders ');
+ assert.dom('[data-test-alert-banner="alert"]').exists('alert banner renders');
+ });
+});
diff --git a/ui/tests/integration/components/search-select-test.js b/ui/tests/integration/components/search-select-test.js
index 1f7421397..f016a20fb 100644
--- a/ui/tests/integration/components/search-select-test.js
+++ b/ui/tests/integration/components/search-select-test.js
@@ -3,7 +3,7 @@ import { setupRenderingTest } from 'ember-qunit';
import { create } from 'ember-cli-page-object';
import { typeInSearch, clickTrigger } from 'ember-power-select/test-support/helpers';
import Service from '@ember/service';
-import { render, settled } from '@ember/test-helpers';
+import { click, render, settled } from '@ember/test-helpers';
import { run } from '@ember/runloop';
import hbs from 'htmlbars-inline-precompile';
import sinon from 'sinon';
@@ -46,6 +46,13 @@ const storeService = Service.extend({
{ id: 'barfoo1', name: 'different' },
]);
break;
+ case 'some/model':
+ resolve([
+ { id: 'model-a-id', name: 'model-a', uuid: 'a123', type: 'a' },
+ { id: 'model-b-id', name: 'model-b', uuid: 'b456', type: 'b' },
+ { id: 'model-c-id', name: 'model-c', uuid: 'c789', type: 'c' },
+ ]);
+ break;
default:
reject({ httpStatus: 404, message: 'not found' });
break;
@@ -255,6 +262,7 @@ module('Integration | Component | search select', function (hooks) {
await typeInSearch('new item');
assert.equal(component.options.objectAt(0).text, 'Add new foo: new item', 'shows the create suggestion');
});
+
test('it shows items not in the returned response', async function (assert) {
const models = ['test'];
this.set('models', models);
@@ -329,4 +337,379 @@ module('Integration | Component | search select', function (hooks) {
'onClick is called with array of objects with isNew true on new item'
);
});
+
+ test(`it returns custom object if passObject=true and multiple objectKeys with objectKeys[0]='id'`, async function (assert) {
+ const models = ['some/model'];
+ const spy = sinon.spy();
+ this.set('models', models);
+ this.set('onChange', spy);
+ this.set('objectKeys', ['id', 'uuid']);
+ await render(hbs`
+
+
+
+ `);
+
+ await clickTrigger();
+ await settled();
+
+ // First select existing option
+ await component.selectOption();
+ assert.equal(component.selectedOptions.length, 1, 'there is 1 selected option');
+ assert
+ .dom('[data-test-selected-option]')
+ .hasText('model-a-id', 'does not render name if first objectKey is id');
+ assert.ok(this.onChange.calledOnce);
+ assert.ok(
+ this.onChange.calledWith([{ id: 'model-a-id', isNew: false, uuid: 'a123' }]),
+ 'onClick is called with array of single object with keys: id, uuid'
+ );
+ // Then create a new item and select it
+ await clickTrigger();
+ await settled();
+ await typeInSearch('newItem');
+ await component.selectOption();
+ await settled();
+ assert.propEqual(
+ spy.args[1][0],
+ [
+ {
+ id: 'model-a-id',
+ isNew: false,
+ uuid: 'a123',
+ },
+ {
+ id: 'newItem',
+ isNew: true,
+ },
+ ],
+ 'onClick is called with array of objects with isNew=true (and no additional keys) on new item'
+ );
+ });
+
+ test('it returns custom object and renders name if passObject=true and multiple objectKeys', async function (assert) {
+ const models = ['some/model'];
+ const spy = sinon.spy();
+ const objectKeys = ['uuid', 'name'];
+ this.set('models', models);
+ this.set('onChange', spy);
+ this.set('objectKeys', objectKeys);
+ await render(hbs`
+
+
+
+ `);
+
+ await clickTrigger();
+ await settled();
+
+ // First select existing option
+ await component.selectOption();
+ assert.equal(component.selectedOptions.length, 1, 'there is 1 selected option');
+ assert
+ .dom('[data-test-selected-option]')
+ .hasText('model-a a123', `renders name and ${objectKeys[0]} if first objectKey is not id`);
+ assert.dom('[data-test-smaller-id]').exists();
+ assert.propEqual(
+ spy.args[0][0],
+ [
+ {
+ id: 'model-a-id',
+ isNew: false,
+ name: 'model-a',
+ uuid: 'a123',
+ },
+ ],
+ `onClick is called with array of single object: isNew=false, and has keys: ${objectKeys.join(', ')}`
+ );
+ });
+
+ test('it renders ids if model does not have the passed objectKeys as an attribute', async function (assert) {
+ const models = ['policy/acl'];
+ const spy = sinon.spy();
+ const objectKeys = ['uuid'];
+ this.set('models', models);
+ this.set('onChange', spy);
+ this.set('objectKeys', objectKeys);
+ await render(hbs`
+
+
+
+ `);
+
+ await clickTrigger();
+ await settled();
+
+ // First select existing option
+ await component.selectOption();
+ assert.equal(component.selectedOptions.length, 1, 'there is 1 selected option');
+ assert
+ .dom('[data-test-selected-option]')
+ .hasText('1', 'renders model id if does not have objectKey as an attribute');
+ assert.propEqual(spy.args[0][0], ['1'], 'onClick is called with array of single id string');
+ });
+
+ test('it renders when passObject=true and model does not have the passed objectKeys as an attr', async function (assert) {
+ const models = ['policy/acl'];
+ const spy = sinon.spy();
+ const objectKeys = ['uuid'];
+ this.set('models', models);
+ this.set('onChange', spy);
+ this.set('objectKeys', objectKeys);
+ await render(hbs`
+
+
+
+ `);
+
+ await clickTrigger();
+ await settled();
+
+ // First select existing option
+ await component.selectOption();
+ assert.equal(component.selectedOptions.length, 1, 'there is 1 selected option');
+ assert.dom('[data-test-selected-option]').hasText('1', 'renders model id if does not have objectKey');
+ assert.propEqual(
+ spy.args[0][0],
+ [
+ {
+ id: '1',
+ isNew: false,
+ },
+ ],
+ 'onClick is called with array of single object with correct keys'
+ );
+ });
+
+ test('it renders when passed multiple models, passObject=true and one model does not have the attr in objectKeys', async function (assert) {
+ const models = ['policy/acl', 'some/model'];
+ const spy = sinon.spy();
+ const objectKeys = ['uuid'];
+ this.set('models', models);
+ this.set('onChange', spy);
+ this.set('objectKeys', objectKeys);
+ await render(hbs`
+
+
+
+ `);
+
+ await clickTrigger();
+ await settled();
+ assert.equal(component.options.objectAt(0).text, '1', 'first option renders just id as name');
+ assert.equal(
+ component.options.objectAt(3).text,
+ 'model-a a123',
+ `4 option renders both name and ${objectKeys[0]}`
+ );
+
+ // First select options with and without id
+ await component.selectOption();
+ await clickTrigger();
+ await settled();
+ await click('[data-option-index="2"]');
+ const expectedArray = [
+ {
+ id: '1',
+ isNew: false,
+ },
+ {
+ id: 'model-a-id',
+ isNew: false,
+ uuid: 'a123',
+ },
+ ];
+ assert.propEqual(
+ spy.args[1][0],
+ expectedArray,
+ `onClick is called with array of objects and correct keys.
+ first object: ${Object.keys(expectedArray[0]).join(', ')},
+ second object: ${Object.keys(expectedArray[1]).join(', ')}`
+ );
+ });
+
+ test('it renders when passed multiple models, passedObject=false and one model does not have the attr in objectKeys', async function (assert) {
+ const models = ['policy/acl', 'some/model'];
+ const spy = sinon.spy();
+ const objectKeys = ['uuid'];
+ this.set('models', models);
+ this.set('onChange', spy);
+ this.set('objectKeys', objectKeys);
+ await render(hbs`
+
+
+
+ `);
+
+ await clickTrigger();
+ await settled();
+ assert.equal(component.options.objectAt(0).text, '1', 'first option is just id as name');
+ assert.equal(
+ component.options.objectAt(3).text,
+ 'model-a a123',
+ `4th option has both name and ${objectKeys[0]}`
+ );
+
+ // First select options with and without id
+ await component.selectOption();
+ await clickTrigger();
+ await settled();
+ await click('[data-option-index="2"]');
+ assert.propEqual(spy.args[1][0], ['1', 'model-a-id'], 'onClick is called with array of id strings');
+ });
+
+ test('it renders an info tooltip beside selection if does not match a record returned from query when passObject=false, passed objectKeys', async function (assert) {
+ const models = ['some/model'];
+ const spy = sinon.spy();
+ const objectKeys = ['uuid'];
+ const inputValue = ['a123', 'non-existent-model'];
+ this.set('models', models);
+ this.set('onChange', spy);
+ this.set('objectKeys', objectKeys);
+ this.set('inputValue', inputValue);
+ await render(hbs`
+
+ `);
+
+ assert.equal(component.selectedOptions.length, 2, 'there are two selected options');
+ assert.dom('[data-test-selected-option="0"]').hasText('model-a');
+ assert.dom('[data-test-selected-option="1"]').hasText('non-existent-model');
+ assert
+ .dom('[data-test-selected-option="0"] [data-test-component="info-tooltip"]')
+ .doesNotExist('does not render info tooltip for model that exists');
+
+ assert
+ .dom('[data-test-selected-option="1"] [data-test-component="info-tooltip"]')
+ .exists('renders info tooltip for model not returned from query');
+ });
+
+ test('it renders an info tooltip beside selection if does not match a record returned from query when passObject=true, passed objectKeys', async function (assert) {
+ const models = ['some/model'];
+ const spy = sinon.spy();
+ const objectKeys = ['uuid'];
+ const inputValue = ['a123', 'non-existent-model'];
+ this.set('models', models);
+ this.set('onChange', spy);
+ this.set('objectKeys', objectKeys);
+ this.set('inputValue', inputValue);
+ await render(hbs`
+
+ `);
+
+ assert.equal(component.selectedOptions.length, 2, 'there are two selected options');
+ assert.dom('[data-test-selected-option="0"]').hasText('model-a a123');
+ assert.dom('[data-test-selected-option="1"]').hasText('non-existent-model');
+ assert
+ .dom('[data-test-selected-option="0"] [data-test-component="info-tooltip"]')
+ .doesNotExist('does not render info tooltip for model that exists');
+
+ assert
+ .dom('[data-test-selected-option="1"] [data-test-component="info-tooltip"]')
+ .exists('renders info tooltip for model not returned from query');
+ });
+
+ test('it renders an info tooltip beside selection if does not match a record returned from query when passObject=true and idKey=id', async function (assert) {
+ const models = ['some/model'];
+ const spy = sinon.spy();
+ const inputValue = ['model-a-id', 'non-existent-model'];
+ this.set('models', models);
+ this.set('onChange', spy);
+ this.set('inputValue', inputValue);
+ await render(hbs`
+
+ `);
+
+ assert.equal(component.selectedOptions.length, 2, 'there are two selected options');
+ assert.dom('[data-test-selected-option="0"]').hasText('model-a-id');
+ assert.dom('[data-test-selected-option="1"]').hasText('non-existent-model');
+ assert
+ .dom('[data-test-selected-option="0"] [data-test-component="info-tooltip"]')
+ .doesNotExist('does not render info tooltip for model that exists');
+
+ assert
+ .dom('[data-test-selected-option="1"] [data-test-component="info-tooltip"]')
+ .exists('renders info tooltip for model not returned from query');
+ });
+
+ test('it renders an info tooltip beside selection if does not match a record returned from query when passObject=false and idKey=id', async function (assert) {
+ const models = ['some/model'];
+ const spy = sinon.spy();
+ const inputValue = ['model-a-id', 'non-existent-model'];
+ this.set('models', models);
+ this.set('onChange', spy);
+ this.set('inputValue', inputValue);
+ await render(hbs`
+
+ `);
+
+ assert.equal(component.selectedOptions.length, 2, 'there are two selected options');
+ assert.dom('[data-test-selected-option="0"]').hasText('model-a-id');
+ assert.dom('[data-test-selected-option="1"]').hasText('non-existent-model');
+ assert
+ .dom('[data-test-selected-option="0"] [data-test-component="info-tooltip"]')
+ .doesNotExist('does not render info tooltip for model that exists');
+
+ assert
+ .dom('[data-test-selected-option="1"] [data-test-component="info-tooltip"]')
+ .exists('renders info tooltip for model not returned from query');
+ });
});
diff --git a/ui/tests/unit/adapters/oidc/assignment-test.js b/ui/tests/unit/adapters/oidc/assignment-test.js
new file mode 100644
index 000000000..16780eb36
--- /dev/null
+++ b/ui/tests/unit/adapters/oidc/assignment-test.js
@@ -0,0 +1,22 @@
+import { module, test } from 'qunit';
+import { setupTest } from 'ember-qunit';
+import { setupMirage } from 'ember-cli-mirage/test-support';
+import testHelper from './test-helper';
+
+module('Unit | Adapter | oidc/assignment', function (hooks) {
+ setupTest(hooks);
+ setupMirage(hooks);
+
+ hooks.beforeEach(function () {
+ this.store = this.owner.lookup('service:store');
+ this.modelName = 'oidc/assignment';
+ this.data = {
+ name: 'foo-assignment',
+ entity_ids: ['my-entity'],
+ group_ids: ['my-group'],
+ };
+ this.path = '/identity/oidc/assignment/foo-assignment';
+ });
+
+ testHelper(test);
+});
diff --git a/ui/tests/unit/adapters/oidc/client-test.js b/ui/tests/unit/adapters/oidc/client-test.js
new file mode 100644
index 000000000..3430985f8
--- /dev/null
+++ b/ui/tests/unit/adapters/oidc/client-test.js
@@ -0,0 +1,23 @@
+import { module, test } from 'qunit';
+import { setupTest } from 'ember-qunit';
+import { setupMirage } from 'ember-cli-mirage/test-support';
+import testHelper from './test-helper';
+
+module('Unit | Adapter | oidc/client', function (hooks) {
+ setupTest(hooks);
+ setupMirage(hooks);
+
+ hooks.beforeEach(function () {
+ this.store = this.owner.lookup('service:store');
+ this.modelName = 'oidc/client';
+ this.data = {
+ name: 'client-1',
+ key: 'test-key',
+ access_token_ttl: '30m',
+ id_token_ttl: '1h',
+ };
+ this.path = '/identity/oidc/client/client-1';
+ });
+
+ testHelper(test);
+});
diff --git a/ui/tests/unit/adapters/oidc/key-test.js b/ui/tests/unit/adapters/oidc/key-test.js
new file mode 100644
index 000000000..74a913aa9
--- /dev/null
+++ b/ui/tests/unit/adapters/oidc/key-test.js
@@ -0,0 +1,33 @@
+import { module, test } from 'qunit';
+import { setupTest } from 'ember-qunit';
+import { setupMirage } from 'ember-cli-mirage/test-support';
+import testHelper from './test-helper';
+
+module('Unit | Adapter | oidc/key', function (hooks) {
+ setupTest(hooks);
+ setupMirage(hooks);
+
+ hooks.beforeEach(function () {
+ this.store = this.owner.lookup('service:store');
+ this.modelName = 'oidc/key';
+ this.data = {
+ name: 'foo-key',
+ rotation_period: '12h',
+ verification_ttl: 43200,
+ };
+ this.path = '/identity/oidc/key/foo-key';
+ });
+
+ testHelper(test);
+
+ test('it should make request to correct endpoint on rotate', async function (assert) {
+ assert.expect(1);
+
+ this.server.post(`${this.path}/rotate`, (schema, req) => {
+ const json = JSON.parse(req.requestBody);
+ assert.equal(json.verification_ttl, '30m', 'request made to correct endpoint on rotate');
+ });
+
+ await this.store.adapterFor('oidc/key').rotate(this.data.name, '30m');
+ });
+});
diff --git a/ui/tests/unit/adapters/oidc/provider-test.js b/ui/tests/unit/adapters/oidc/provider-test.js
new file mode 100644
index 000000000..0c78b2308
--- /dev/null
+++ b/ui/tests/unit/adapters/oidc/provider-test.js
@@ -0,0 +1,22 @@
+import { module, test } from 'qunit';
+import { setupTest } from 'ember-qunit';
+import { setupMirage } from 'ember-cli-mirage/test-support';
+import testHelper from './test-helper';
+
+module('Unit | Adapter | oidc/provider', function (hooks) {
+ setupTest(hooks);
+ setupMirage(hooks);
+
+ hooks.beforeEach(function () {
+ this.store = this.owner.lookup('service:store');
+ this.modelName = 'oidc/provider';
+ this.data = {
+ name: 'foo-provider',
+ allowed_client_ids: ['*'],
+ scopes_supported: [],
+ };
+ this.path = '/identity/oidc/provider/foo-provider';
+ });
+
+ testHelper(test);
+});
diff --git a/ui/tests/unit/adapters/oidc/scope-test.js b/ui/tests/unit/adapters/oidc/scope-test.js
new file mode 100644
index 000000000..e2d51871f
--- /dev/null
+++ b/ui/tests/unit/adapters/oidc/scope-test.js
@@ -0,0 +1,22 @@
+import { module, test } from 'qunit';
+import { setupTest } from 'ember-qunit';
+import { setupMirage } from 'ember-cli-mirage/test-support';
+import testHelper from './test-helper';
+
+module('Unit | Adapter | oidc/key', function (hooks) {
+ setupTest(hooks);
+ setupMirage(hooks);
+
+ hooks.beforeEach(function () {
+ this.store = this.owner.lookup('service:store');
+ this.modelName = 'oidc/scope';
+ this.data = {
+ name: 'foo-scope',
+ template: '{ "groups": {{identity.entity.groups.names}} }',
+ description: 'A simple scope example.',
+ };
+ this.path = '/identity/oidc/scope/foo-scope';
+ });
+
+ testHelper(test);
+});
diff --git a/ui/tests/unit/adapters/oidc/test-helper.js b/ui/tests/unit/adapters/oidc/test-helper.js
new file mode 100644
index 000000000..9df7880ac
--- /dev/null
+++ b/ui/tests/unit/adapters/oidc/test-helper.js
@@ -0,0 +1,165 @@
+export default (test) => {
+ test('it should make request to correct endpoint on save', async function (assert) {
+ assert.expect(1);
+
+ this.server.post(this.path, () => {
+ assert.ok(true, 'request made to correct endpoint on save');
+ });
+
+ const model = this.store.createRecord(this.modelName, this.data);
+ await model.save();
+ });
+
+ test('it should throw error if attempting to createRecord with an existing name', async function (assert) {
+ const { modelName, data } = this;
+ this.store.pushPayload(modelName, { modelName, name: data.name });
+
+ const model = this.store.createRecord(modelName, data);
+ assert.rejects(model.save(), `Error: A record already exists with the name: ${data.name}`);
+ });
+
+ test('it should make request to correct endpoint on find', async function (assert) {
+ assert.expect(1);
+
+ this.server.get(this.path, () => {
+ assert.ok(true, 'request is made to correct endpoint on find');
+ return { data: this.data };
+ });
+
+ this.store.findRecord(this.modelName, this.data.name);
+ });
+
+ test('it should make request to correct endpoint on query', async function (assert) {
+ const keyInfoModels = ['client', 'provider']; // these models have key_info on the LIST response
+ const { name, ...otherAttrs } = this.data; // excludes name from key_info data
+ const key_info = { [name]: { ...otherAttrs } };
+
+ this.server.get(`/identity/${this.modelName}`, (schema, req) => {
+ assert.equal(req.queryParams.list, 'true', 'request is made to correct endpoint on query');
+ if (keyInfoModels.some((model) => this.modelName.includes(model))) {
+ return { data: { keys: [name], key_info } };
+ } else {
+ return { data: { keys: [name] } };
+ }
+ });
+
+ this.store.query(this.modelName, {});
+ });
+
+ test('it should filter query when passed filterFor and paramKey', async function (assert) {
+ const keyInfoModels = ['client', 'provider']; // these models have key_info on the LIST response
+ const keys = ['model-1', 'model-2', 'model-3'];
+ const key_info = {
+ 'model-1': {
+ model_id: 'a123',
+ key: 'test-key',
+ access_token_ttl: '30m',
+ id_token_ttl: '1h',
+ },
+ 'model-2': {
+ model_id: 'b123',
+ key: 'test-key',
+ access_token_ttl: '30m',
+ id_token_ttl: '1h',
+ },
+ 'model-3': {
+ model_id: 'c123',
+ key: 'test-key',
+ access_token_ttl: '30m',
+ id_token_ttl: '1h',
+ },
+ };
+
+ this.server.get(`/identity/${this.modelName}`, () => {
+ if (keyInfoModels.some((model) => this.modelName.includes(model))) {
+ return { data: { keys, key_info } };
+ } else {
+ return { data: { keys: [this.data.name] } };
+ }
+ });
+
+ // test passing 'paramKey' and 'filterFor' to query and filterListResponse in adapters/named-path.js works as expected
+ if (keyInfoModels.some((model) => this.modelName.includes(model))) {
+ let testQuery = ['*', 'a123'];
+ await this.store
+ .query(this.modelName, { paramKey: 'model_id', filterFor: testQuery })
+ .then((resp) => assert.equal(resp.content.length, 3, 'returns all models when ids include glob (*)'));
+
+ testQuery = ['*'];
+ await this.store
+ .query(this.modelName, { paramKey: 'model_id', filterFor: testQuery })
+ .then((resp) => assert.equal(resp.content.length, 3, 'returns all models when glob (*) is only id'));
+
+ testQuery = ['b123'];
+ await this.store.query(this.modelName, { paramKey: 'model_id', filterFor: testQuery }).then((resp) => {
+ assert.equal(resp.content.length, 1, 'filters response and returns only matching id');
+
+ assert.equal(resp.firstObject.name, 'model-2', 'response contains correct model');
+ });
+
+ testQuery = ['b123', 'c123'];
+ await this.store.query(this.modelName, { paramKey: 'model_id', filterFor: testQuery }).then((resp) => {
+ assert.equal(resp.content.length, 2, 'filters response when passed multiple ids');
+ resp.content.forEach((m) =>
+ assert.ok(['model-2', 'model-3'].includes(m.id), `it filters correctly and included: ${m.id}`)
+ );
+ });
+
+ await this.store
+ .query(this.modelName, { paramKey: 'nonexistent_key', filterFor: testQuery })
+ .then((resp) => assert.ok(resp.isLoaded, 'does not error when paramKey does not exist'));
+
+ assert.rejects(
+ this.store.query(this.modelName, { paramKey: 'model_id', filterFor: 'some-string' }),
+ 'throws assertion when filterFor is not an array'
+ );
+ } else {
+ let testQuery = ['b123', 'c123'];
+ await this.store
+ .query(this.modelName, { paramKey: 'model_id', filterFor: testQuery })
+ .then((resp) => assert.ok(resp.isLoaded, 'does not error when key_info does not exist'));
+ }
+ });
+
+ test('it passes allowed_client_id only when the param exists', async function (assert) {
+ const keyInfoModels = ['client', 'provider']; // these models have key_info on the LIST response
+ const { name, ...otherAttrs } = this.data; // excludes name from key_info data
+ const key_info = { [name]: { ...otherAttrs } };
+
+ this.server.get(`/identity/${this.modelName}`, (schema, req) => {
+ if (this.modelName === 'oidc/provider') {
+ assert.propEqual(
+ req.queryParams,
+ { list: 'true', allowed_client_id: 'a123' },
+ 'request has allowed_client_id as query param'
+ );
+ } else {
+ assert.propEqual(req.queryParams, { list: 'true' }, 'request only has `list` param');
+ }
+ if (keyInfoModels.some((model) => this.modelName.includes(model))) {
+ return { data: { keys: [name], key_info } };
+ } else {
+ return { data: { keys: [name] } };
+ }
+ });
+
+ // only /provider accepts an allowed_client_id
+ if (this.modelName === 'oidc/provider') {
+ this.store.query(this.modelName, { allowed_client_id: 'a123' });
+ } else {
+ this.store.query(this.modelName, {});
+ }
+ });
+
+ test('it should make request to correct endpoint on delete', async function (assert) {
+ assert.expect(1);
+
+ this.server.get(this.path, () => ({ data: this.data }));
+ this.server.delete(this.path, () => {
+ assert.ok(true, 'request made to correct endpoint on delete');
+ });
+
+ const model = await this.store.findRecord(this.modelName, this.data.name);
+ await model.destroyRecord();
+ });
+};