UI: add pagination to new PKI (#23193) (#23239)

* UI: add pagination to new PKI (#23193)

* fixes store type import

* fixes tests

---------

Co-authored-by: Jordan Reimer <zofskeez@gmail.com>
This commit is contained in:
Chelsea Shaw 2023-09-22 11:47:55 -05:00 committed by GitHub
parent c29b24b07d
commit 36452c0849
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 658 additions and 266 deletions

3
changelog/23193.txt Normal file
View File

@ -0,0 +1,3 @@
```release-note:improvement
ui: Add pagination to PKI roles, keys, issuers, and certificates list pages
```

View File

@ -77,9 +77,12 @@ export default Store.extend({
// pageFilter: a string that will be used to do a fuzzy match against the // pageFilter: a string that will be used to do a fuzzy match against the
// results, this is done pre-pagination // results, this is done pre-pagination
lazyPaginatedQuery(modelType, query /*, options*/) { lazyPaginatedQuery(modelType, query /*, options*/) {
const skipCache = query.skipCache;
// We don't want skipCache to be part of the actual query key, so remove it
delete query.skipCache;
const adapter = this.adapterFor(modelType); const adapter = this.adapterFor(modelType);
const modelName = normalizeModelName(modelType); const modelName = normalizeModelName(modelType);
const dataCache = this.getDataset(modelName, query); const dataCache = skipCache ? this.clearDataset(modelName) : this.getDataset(modelName, query);
const responsePath = query.responsePath; const responsePath = query.responsePath;
assert('responsePath is required', responsePath); assert('responsePath is required', responsePath);
assert('page is required', typeof query.page === 'number'); assert('page is required', typeof query.page === 'number');
@ -144,6 +147,7 @@ export default Store.extend({
prevPage: clamp(currentPage - 1, 1, lastPage), prevPage: clamp(currentPage - 1, 1, lastPage),
total: dataset.length || 0, total: dataset.length || 0,
filteredTotal: data.length || 0, filteredTotal: data.length || 0,
pageSize: size,
}; };
return response; return response;

View File

@ -1,65 +1,111 @@
{{#each @issuers as |pkiIssuer idx|}} <PkiPaginatedList @listRoute="issuers.index" @list={{@issuers}}>
<LinkedBlock class="list-item-row" @params={{array "issuers.issuer.details" pkiIssuer.id}} @linkPrefix={{@mountPoint}}> <:actions>
<div class="level is-mobile"> <ToolbarLink @route="issuers.import" data-test-generate-issuer="import">
<div class="level-left"> Import
<div data-test-issuer-list={{pkiIssuer.id}}> </ToolbarLink>
<Icon @name="certificate" class="has-text-grey-light" /> <BasicDropdown @class="popup-menu" @horizontalPosition="auto-right" @verticalPosition="below" as |D|>
<span class="has-text-weight-semibold is-underline"> <D.Trigger
{{pkiIssuer.issuerRef}} class={{concat "toolbar-link" (if D.isOpen " is-active")}}
{{#if pkiIssuer.issuerName}} @htmlTag="button"
<span class="tag has-text-grey-dark">{{pkiIssuer.id}}</span> data-test-issuer-generate-dropdown
{{/if}} >
</span> Generate
<div class="is-flex-row has-left-margin-l has-top-margin-xs"> <Chevron @direction="down" @isButton={{true}} />
{{#if pkiIssuer.isDefault}} </D.Trigger>
<span class="tag has-text-grey-dark" data-test-is-default={{idx}}>default issuer</span> <D.Content @defaultClass="popup-menu-content">
{{/if}} <nav class="box menu" aria-label="generate options">
{{#if (not (eq pkiIssuer.isRoot undefined))}} <ul class="menu-list">
<span class="tag has-text-grey-dark" data-test-is-root-tag={{idx}}>{{if <li class="action">
pkiIssuer.isRoot <LinkTo @route="issuers.generate-root" {{on "click" (fn this.onLinkClick D)}} data-test-generate-issuer="root">
"root" Root
"intermediate" </LinkTo>
}}</span> </li>
{{/if}} <li class="action">
{{#if pkiIssuer.serialNumber}} <LinkTo
<span class="tag is-transparent has-right-margin-none" data-test-serial-number={{idx}}> @route="issuers.generate-intermediate"
<InfoTooltip> {{on "click" (fn this.onLinkClick D)}}
Serial number data-test-generate-issuer="intermediate"
</InfoTooltip> >
{{pkiIssuer.serialNumber}} Intermediate CSR
</LinkTo>
</li>
</ul>
</nav>
</D.Content>
</BasicDropdown>
</:actions>
<:list as |issuers|>
{{#each issuers as |pkiIssuer idx|}}
<LinkedBlock class="list-item-row" @params={{array "issuers.issuer.details" pkiIssuer.id}} @linkPrefix={{@mountPoint}}>
<div class="level is-mobile">
<div class="level-left">
<div data-test-issuer-list={{pkiIssuer.id}}>
<Icon @name="certificate" class="has-text-grey-light" />
<span class="has-text-weight-semibold is-underline">
{{pkiIssuer.issuerRef}}
{{#if pkiIssuer.issuerName}}
<span class="tag has-text-grey-dark">{{pkiIssuer.id}}</span>
{{/if}}
</span> </span>
{{/if}} <div class="is-flex-row has-left-margin-l has-top-margin-xs">
{{#if pkiIssuer.parsedCertificate.common_name}} {{#if pkiIssuer.isDefault}}
<span class="tag is-transparent has-left-margin-none" data-test-common-name={{idx}}> <span class="tag has-text-grey-dark" data-test-is-default={{idx}}>default issuer</span>
<InfoTooltip> {{/if}}
Common name {{#if (not-eq pkiIssuer.isRoot undefined)}}
</InfoTooltip> <span class="tag has-text-grey-dark" data-test-is-root-tag={{idx}}>{{if
{{pkiIssuer.parsedCertificate.common_name}} pkiIssuer.isRoot
</span> "root"
{{/if}} "intermediate"
}}</span>
{{/if}}
{{#if pkiIssuer.serialNumber}}
<span class="tag is-transparent has-right-margin-none" data-test-serial-number={{idx}}>
<InfoTooltip>
Serial number
</InfoTooltip>
{{pkiIssuer.serialNumber}}
</span>
{{/if}}
{{#if pkiIssuer.parsedCertificate.common_name}}
<span class="tag is-transparent has-left-margin-none" data-test-common-name={{idx}}>
<InfoTooltip>
Common name
</InfoTooltip>
{{pkiIssuer.parsedCertificate.common_name}}
</span>
{{/if}}
</div>
</div>
</div>
<div class="level-right is-flex is-paddingless is-marginless">
<div class="level-item">
<PopupMenu>
<nav class="menu" aria-label="issuer config options">
<ul class="menu-list">
<li data-test-popup-menu-details>
<LinkTo @route="issuers.issuer.details" @model={{pkiIssuer.id}}>
Details
</LinkTo>
</li>
<li>
<LinkTo @route="issuers.issuer.edit" @model={{pkiIssuer.id}}>
Edit
</LinkTo>
</li>
</ul>
</nav>
</PopupMenu>
</div>
</div> </div>
</div> </div>
</div> </LinkedBlock>
<div class="level-right is-flex is-paddingless is-marginless"> {{/each}}
<div class="level-item"> </:list>
<PopupMenu> <:empty>
<nav class="menu" aria-label="issuer config options"> <EmptyState @title="PKI not configured" @message={{this.notConfiguredMessage}}>
<ul class="menu-list"> <LinkTo @route="configuration.create">
<li data-test-popup-menu-details> Configure PKI
<LinkTo @route="issuers.issuer.details" @model={{pkiIssuer.id}}> </LinkTo>
Details </EmptyState>
</LinkTo> </:empty>
</li> </PkiPaginatedList>
<li>
<LinkTo @route="issuers.issuer.edit" @model={{pkiIssuer.id}}>
Edit
</LinkTo>
</li>
</ul>
</nav>
</PopupMenu>
</div>
</div>
</div>
</LinkedBlock>
{{/each}}

View File

@ -0,0 +1,29 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { action } from '@ember/object';
import { next } from '@ember/runloop';
import Component from '@glimmer/component';
import { PKI_DEFAULT_EMPTY_STATE_MSG } from 'pki/routes/overview';
import type PkiIssuerModel from 'vault/models/pki/issuer';
interface BasicDropdown {
actions: {
close: CallableFunction;
};
}
interface Args {
issuers: PkiIssuerModel[];
mountPoint: string;
}
export default class PkiIssuerList extends Component<Args> {
notConfiguredMessage = PKI_DEFAULT_EMPTY_STATE_MSG;
// To prevent production build bug of passing D.actions to on "click": https://github.com/hashicorp/vault/pull/16983
@action onLinkClick(D: BasicDropdown) {
next(() => D.actions.close());
}
}

View File

@ -1,5 +1,5 @@
<Toolbar> <PkiPaginatedList @listRoute="keys.index" @list={{@keyModels}} @hasConfig={{@hasConfig}}>
<ToolbarActions> <:actions>
{{#if @canImportKey}} {{#if @canImportKey}}
<ToolbarLink @route="keys.import" @type="download" data-test-pki-key-import> <ToolbarLink @route="keys.import" @type="download" data-test-pki-key-import>
Import Import
@ -10,62 +10,73 @@
Generate Generate
</ToolbarLink> </ToolbarLink>
{{/if}} {{/if}}
</ToolbarActions> </:actions>
</Toolbar> <:description>
<p class="has-padding">Below is information about the private keys used by the issuers to sign certificates. While <p class="has-padding">Below is information about the private keys used by the issuers to sign certificates. While
certificates represent a public assertion of an identity, private keys represent the private part of that identity, a certificates represent a public assertion of an identity, private keys represent the private part of that identity, a
secret used to prove who they are and who they trust.</p> secret used to prove who they are and who they trust.</p>
</:description>
{{#if @keyModels.length}} <:list as |keys|>
{{#each @keyModels as |pkiKey|}} {{#each keys as |pkiKey|}}
<LinkedBlock class="list-item-row" @params={{array "keys.key.details" pkiKey.keyId}} @linkPrefix={{@mountPoint}}> <LinkedBlock class="list-item-row" @params={{array "keys.key.details" pkiKey.keyId}} @linkPrefix={{@mountPoint}}>
<div class="level is-mobile"> <div class="level is-mobile">
<div class="level-left"> <div class="level-left">
<div> <div>
<Icon @name="certificate" class="has-text-grey-light" /> <Icon @name="certificate" class="has-text-grey-light" />
<span class="has-text-weight-semibold is-underline" data-test-key={{if pkiKey.keyName "name" "id"}}> <span class="has-text-weight-semibold is-underline" data-test-key={{if pkiKey.keyName "name" "id"}}>
{{or pkiKey.keyName pkiKey.id}} {{or pkiKey.keyName pkiKey.id}}
</span> </span>
<div class="is-flex-row has-left-margin-l has-top-margin-xs"> <div class="is-flex-row has-left-margin-l has-top-margin-xs">
{{#if pkiKey.keyName}} {{#if pkiKey.keyName}}
<span class="tag has-text-grey-dark" data-test-key="id">{{pkiKey.id}}</span> <span class="tag has-text-grey-dark" data-test-key="id">{{pkiKey.id}}</span>
{{/if}} {{/if}}
</div>
</div>
</div>
<div class="level-right is-flex is-paddingless is-marginless">
<div class="level-item">
<PopupMenu>
<nav class="menu">
<ul class="menu-list">
<li>
<LinkTo
@route="keys.key.details"
@model={{pkiKey.keyId}}
@disabled={{not @canRead}}
data-test-key-menu-link="details"
>
Details
</LinkTo>
</li>
<li>
<LinkTo
@route="keys.key.edit"
@model={{pkiKey.keyId}}
@disabled={{not @canEdit}}
data-test-key-menu-link="edit"
>
Edit
</LinkTo>
</li>
</ul>
</nav>
</PopupMenu>
</div> </div>
</div> </div>
</div> </div>
<div class="level-right is-flex is-paddingless is-marginless"> </LinkedBlock>
<div class="level-item"> {{/each}}
<PopupMenu> </:list>
<nav class="menu">
<ul class="menu-list"> <:empty>
<li> <EmptyState @title="No keys yet" @message="There are no keys in this PKI mount. You can generate or create one." />
<LinkTo </:empty>
@route="keys.key.details"
@model={{pkiKey.keyId}} <:configure>
@disabled={{not @canRead}} <EmptyState @title="PKI not configured" @message={{this.notConfiguredMessage}}>
data-test-key-menu-link="details" <LinkTo @route="configuration.create">
> Configure PKI
Details </LinkTo>
</LinkTo> </EmptyState>
</li> </:configure>
<li> </PkiPaginatedList>
<LinkTo
@route="keys.key.edit"
@model={{pkiKey.keyId}}
@disabled={{not @canEdit}}
data-test-key-menu-link="edit"
>
Edit
</LinkTo>
</li>
</ul>
</nav>
</PopupMenu>
</div>
</div>
</div>
</LinkedBlock>
{{/each}}
{{else}}
<EmptyState @title="No keys yet" @message="There are no keys in this PKI mount. You can generate or create one." />
{{/if}}

View File

@ -0,0 +1,22 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Component from '@glimmer/component';
import { PKI_DEFAULT_EMPTY_STATE_MSG } from 'pki/routes/overview';
import type PkiKeyModel from 'vault/models/pki/key';
interface Args {
keyModels: PkiKeyModel[];
mountPoint: string;
canImportKey: boolean;
canGenerateKey: boolean;
canRead: boolean;
canEdit: boolean;
hasConfig: boolean;
}
export default class PkiKeyList extends Component<Args> {
notConfiguredMessage = PKI_DEFAULT_EMPTY_STATE_MSG;
}

View File

@ -0,0 +1,25 @@
<Toolbar>
<ToolbarActions>
{{yield to="actions"}}
</ToolbarActions>
</Toolbar>
{{#if this.hasConfig}}
{{#if @list.meta.total}}
{{yield to="description"}}
{{yield @list to="list"}}
<Hds::Pagination::Numbered
@currentPage={{@list.meta.currentPage}}
@currentPageSize={{@list.meta.pageSize}}
@route={{@listRoute}}
@showSizeSelector={{false}}
@totalItems={{@list.meta.total}}
@queryFunction={{this.paginationQueryParams}}
data-test-pagination
/>
{{else}}
{{yield to="empty"}}
{{/if}}
{{else}}
{{yield to="configure"}}
{{/if}}

View File

@ -0,0 +1,33 @@
import Component from '@glimmer/component';
/**
* @module AuthForm
* The `PkiPaginatedList` is used to handle a list page layout with lazyPagination response.
* It is specific to PKI so we can make certain assumptions about routing.
* The toolbar has no filtering since users can go directly to an item from the overview page.
*
* @example ```js
* <PkiPaginatedList @list={{this.model.roles}} @hasConfig={{this.model.hasConfig}} @listRoute="roles.index">
* <:list as |items|>
* {{#each items as |item}}
* <div>for each thing</div>
* {{/each}}
* </:list>
* </PkiPaginatedList>
* ```
*/
interface Args {
list: unknown[];
listRoute: string;
hasConfig?: boolean;
}
export default class PkiPaginatedListComponent extends Component<Args> {
get paginationQueryParams() {
return (page: number) => ({ page });
}
get hasConfig() {
if (typeof this.args.hasConfig === 'boolean') return this.args.hasConfig;
return true;
}
}

View File

@ -1,5 +1,5 @@
{{#if @model.serialNumber}} {{#if @model.serialNumber}}
<Page::PkiCertificateDetails @model={{@model}} @onRevoke={{this.cancel}} @onBack={{this.cancel}} /> <Page::PkiCertificateDetails @model={{@model}} @onBack={{this.cancel}} />
{{else}} {{else}}
<form {{on "submit" (perform this.save)}} data-test-pki-generate-cert-form> <form {{on "submit" (perform this.save)}} data-test-pki-generate-cert-form>
<div class="box is-bottomless is-fullwidth is-marginless"> <div class="box is-bottomless is-fullwidth is-marginless">

View File

@ -8,6 +8,8 @@ import { getOwner } from '@ember/application';
import { action } from '@ember/object'; import { action } from '@ember/object';
export default class PkiCertificatesIndexController extends Controller { export default class PkiCertificatesIndexController extends Controller {
queryParams = ['page'];
get mountPoint() { get mountPoint() {
return getOwner(this).mountPoint; return getOwner(this).mountPoint;
} }

View File

@ -4,16 +4,12 @@
*/ */
import Controller from '@ember/controller'; import Controller from '@ember/controller';
import { action } from '@ember/object';
import { next } from '@ember/runloop';
import { getOwner } from '@ember/application'; import { getOwner } from '@ember/application';
export default class PkiIssuerIndexController extends Controller { export default class PkiIssuerIndexController extends Controller {
queryParams = ['page'];
get mountPoint() { get mountPoint() {
return getOwner(this).mountPoint; return getOwner(this).mountPoint;
} }
// To prevent production build bug of passing D.actions to on "click": https://github.com/hashicorp/vault/pull/16983
@action onLinkClick(D) {
next(() => D.actions.close());
}
} }

View File

@ -7,6 +7,8 @@ import Controller from '@ember/controller';
import { getOwner } from '@ember/application'; import { getOwner } from '@ember/application';
export default class PkiKeysIndexController extends Controller { export default class PkiKeysIndexController extends Controller {
queryParams = ['page'];
get mountPoint() { get mountPoint() {
return getOwner(this).mountPoint; return getOwner(this).mountPoint;
} }

View File

@ -7,6 +7,8 @@ import Controller from '@ember/controller';
import { getOwner } from '@ember/application'; import { getOwner } from '@ember/application';
export default class PkiRolesIndexController extends Controller { export default class PkiRolesIndexController extends Controller {
queryParams = ['page'];
get mountPoint() { get mountPoint() {
return getOwner(this).mountPoint; return getOwner(this).mountPoint;
} }

View File

@ -14,23 +14,35 @@ export default class PkiCertificatesIndexRoute extends Route {
@service store; @service store;
@service secretMountPath; @service secretMountPath;
async fetchCertificates() { queryParams = {
page: {
refreshModel: true,
},
};
async fetchCertificates(params) {
try { try {
return await this.store.query('pki/certificate/base', { backend: this.secretMountPath.currentPath }); const page = Number(params.page) || 1;
return await this.store.lazyPaginatedQuery('pki/certificate/base', {
backend: this.secretMountPath.currentPath,
responsePath: 'data.keys',
page,
skipCache: page === 1,
});
} catch (e) { } catch (e) {
if (e.httpStatus === 404) { if (e.httpStatus === 404) {
return { parentModel: this.modelFor('certificates') }; return { parentModel: this.modelFor('certificates') };
} else {
throw e;
} }
throw e;
} }
} }
model() { model(params) {
return hash({ return hash({
hasConfig: this.shouldPromptConfig, hasConfig: this.shouldPromptConfig,
certificates: this.fetchCertificates(), certificates: this.fetchCertificates(params),
parentModel: this.modelFor('certificates'), parentModel: this.modelFor('certificates'),
pageFilter: params.pageFilter,
}); });
} }
@ -41,4 +53,10 @@ export default class PkiCertificatesIndexRoute extends Route {
if (certificates?.length) controller.notConfiguredMessage = getCliMessage('certificates'); if (certificates?.length) controller.notConfiguredMessage = getCliMessage('certificates');
else controller.notConfiguredMessage = getCliMessage(); else controller.notConfiguredMessage = getCliMessage();
} }
resetController(controller, isExiting) {
if (isExiting) {
controller.set('page', undefined);
}
}
} }

View File

@ -5,15 +5,21 @@
import Route from '@ember/routing/route'; import Route from '@ember/routing/route';
import { inject as service } from '@ember/service'; import { inject as service } from '@ember/service';
import { PKI_DEFAULT_EMPTY_STATE_MSG } from 'pki/routes/overview';
export default class PkiIssuersListRoute extends Route { export default class PkiIssuersListRoute extends Route {
@service store; @service store;
@service secretMountPath; @service secretMountPath;
model() { model(params) {
const page = Number(params.page) || 1;
return this.store return this.store
.query('pki/issuer', { backend: this.secretMountPath.currentPath, isListView: true }) .lazyPaginatedQuery('pki/issuer', {
backend: this.secretMountPath.currentPath,
responsePath: 'data.keys',
page,
skipCache: page === 1,
isListView: true,
})
.then((issuersModel) => { .then((issuersModel) => {
return { issuersModel, parentModel: this.modelFor('issuers') }; return { issuersModel, parentModel: this.modelFor('issuers') };
}) })
@ -33,6 +39,11 @@ export default class PkiIssuersListRoute extends Route {
{ label: this.secretMountPath.currentPath, route: 'overview' }, { label: this.secretMountPath.currentPath, route: 'overview' },
{ label: 'issuers', route: 'issuers.index' }, { label: 'issuers', route: 'issuers.index' },
]; ];
controller.notConfiguredMessage = PKI_DEFAULT_EMPTY_STATE_MSG; }
resetController(controller, isExiting) {
if (isExiting) {
controller.set('page', undefined);
}
} }
} }

View File

@ -14,17 +14,31 @@ export default class PkiKeysIndexRoute extends Route {
@service secretMountPath; @service secretMountPath;
@service store; @service store;
model() { queryParams = {
page: {
refreshModel: true,
},
};
model(params) {
const page = Number(params.page) || 1;
return hash({ return hash({
hasConfig: this.shouldPromptConfig, hasConfig: this.shouldPromptConfig,
parentModel: this.modelFor('keys'), parentModel: this.modelFor('keys'),
keyModels: this.store.query('pki/key', { backend: this.secretMountPath.currentPath }).catch((err) => { keyModels: this.store
if (err.httpStatus === 404) { .lazyPaginatedQuery('pki/key', {
return []; backend: this.secretMountPath.currentPath,
} else { responsePath: 'data.keys',
throw err; page,
} skipCache: page === 1,
}), })
.catch((err) => {
if (err.httpStatus === 404) {
return [];
} else {
throw err;
}
}),
}); });
} }
@ -37,4 +51,10 @@ export default class PkiKeysIndexRoute extends Route {
]; ];
controller.notConfiguredMessage = PKI_DEFAULT_EMPTY_STATE_MSG; controller.notConfiguredMessage = PKI_DEFAULT_EMPTY_STATE_MSG;
} }
resetController(controller, isExiting) {
if (isExiting) {
controller.set('page', undefined);
}
}
} }

View File

@ -13,23 +13,35 @@ export default class PkiRolesIndexRoute extends Route {
@service store; @service store;
@service secretMountPath; @service secretMountPath;
async fetchRoles() { queryParams = {
page: {
refreshModel: true,
},
};
async fetchRoles(params) {
try { try {
return await this.store.query('pki/role', { backend: this.secretMountPath.currentPath }); const page = Number(params.page) || 1;
return await this.store.lazyPaginatedQuery('pki/role', {
backend: this.secretMountPath.currentPath,
responsePath: 'data.keys',
page,
skipCache: page === 1,
});
} catch (e) { } catch (e) {
if (e.httpStatus === 404) { if (e.httpStatus === 404) {
return { parentModel: this.modelFor('roles') }; return { parentModel: this.modelFor('roles') };
} else {
throw e;
} }
throw e;
} }
} }
model() { model(params) {
return hash({ return hash({
hasConfig: this.shouldPromptConfig, hasConfig: this.shouldPromptConfig,
roles: this.fetchRoles(), roles: this.fetchRoles(params),
parentModel: this.modelFor('roles'), parentModel: this.modelFor('roles'),
pageFilter: params.pageFilter,
}); });
} }
@ -40,4 +52,10 @@ export default class PkiRolesIndexRoute extends Route {
if (roles?.length) controller.notConfiguredMessage = getCliMessage('roles'); if (roles?.length) controller.notConfiguredMessage = getCliMessage('roles');
else controller.notConfiguredMessage = getCliMessage(); else controller.notConfiguredMessage = getCliMessage();
} }
resetController(controller, isExiting) {
if (isExiting) {
controller.set('page', undefined);
}
}
} }

View File

@ -8,18 +8,10 @@
}} }}
@isEngine={{true}} @isEngine={{true}}
/> />
{{outlet}}
<Toolbar>
{{#if this.model.certificates.length}}
<ToolbarFilters>
{{! TODO add NavigateInput component }}
</ToolbarFilters>
{{/if}}
</Toolbar>
{{#if this.model.hasConfig}} <PkiPaginatedList @listRoute="certificates.index" @list={{this.model.certificates}} @hasConfig={{this.model.hasConfig}}>
{{#if this.model.certificates.length}} <:list as |certs|>
{{#each this.model.certificates as |pkiCertificate|}} {{#each certs as |pkiCertificate|}}
<LinkedBlock <LinkedBlock
class="list-item-row" class="list-item-row"
@params={{array "certificates.certificate.details" pkiCertificate.id}} @params={{array "certificates.certificate.details" pkiCertificate.id}}
@ -55,7 +47,8 @@
</div> </div>
</LinkedBlock> </LinkedBlock>
{{/each}} {{/each}}
{{else}} </:list>
<:empty>
<EmptyState @title="No certificates yet"> <EmptyState @title="No certificates yet">
<div> <div>
<p>When created, certificates will be listed here. Select a role to start generating certificates.</p> <p>When created, certificates will be listed here. Select a role to start generating certificates.</p>
@ -66,11 +59,12 @@
</div> </div>
</div> </div>
</EmptyState> </EmptyState>
{{/if}} </:empty>
{{else}} <:configure>
<EmptyState @title="PKI not configured" @message={{this.notConfiguredMessage}}> <EmptyState @title="PKI not configured" @message={{this.notConfiguredMessage}}>
<LinkTo @route="configuration.create"> <LinkTo @route="configuration.create">
Configure PKI Configure PKI
</LinkTo> </LinkTo>
</EmptyState> </EmptyState>
{{/if}} </:configure>
</PkiPaginatedList>

View File

@ -8,50 +8,5 @@
}} }}
@isEngine={{true}} @isEngine={{true}}
/> />
<Toolbar>
<ToolbarActions>
<ToolbarLink @route="issuers.import" data-test-generate-issuer="import">
Import
</ToolbarLink>
<BasicDropdown @class="popup-menu" @horizontalPosition="auto-right" @verticalPosition="below" as |D|>
<D.Trigger
class={{concat "toolbar-link" (if D.isOpen " is-active")}}
@htmlTag="button"
data-test-issuer-generate-dropdown
>
Generate
<Chevron @direction="down" @isButton={{true}} />
</D.Trigger>
<D.Content @defaultClass="popup-menu-content">
<nav class="box menu" aria-label="generate options">
<ul class="menu-list">
<li class="action">
<LinkTo @route="issuers.generate-root" {{on "click" (fn this.onLinkClick D)}} data-test-generate-issuer="root">
Root
</LinkTo>
</li>
<li class="action">
<LinkTo
@route="issuers.generate-intermediate"
{{on "click" (fn this.onLinkClick D)}}
data-test-generate-issuer="intermediate"
>
Intermediate CSR
</LinkTo>
</li>
</ul>
</nav>
</D.Content>
</BasicDropdown>
</ToolbarActions>
</Toolbar>
{{#if this.model.issuersModel.length}} <Page::PkiIssuerList @issuers={{this.model.issuersModel}} @mountPoint={{this.mountPoint}} />
<Page::PkiIssuerList @issuers={{this.model.issuersModel}} @mountPoint={{this.mountPoint}} />
{{else}}
<EmptyState @title="PKI not configured" @message={{this.notConfiguredMessage}}>
<LinkTo @route="configuration.create">
Configure PKI
</LinkTo>
</EmptyState>
{{/if}}

View File

@ -8,20 +8,13 @@
}} }}
@isEngine={{true}} @isEngine={{true}}
/> />
{{#if (or this.model.hasConfig this.model.keyModels)}}
<Page::PkiKeyList <Page::PkiKeyList
@keyModels={{this.model.keyModels}} @keyModels={{this.model.keyModels}}
@mountPoint={{this.mountPoint}} @mountPoint={{this.mountPoint}}
@canImportKey={{this.model.keyModels.firstObject.canImportKey}} @canImportKey={{this.model.keyModels.firstObject.canImportKey}}
@canGenerateKey={{this.model.keyModels.firstObject.canGenerateKey}} @canGenerateKey={{this.model.keyModels.firstObject.canGenerateKey}}
@canRead={{this.model.keyModels.firstObject.canRead}} @canRead={{this.model.keyModels.firstObject.canRead}}
@canEdit={{this.model.keyModels.firstObject.canEdit}} @canEdit={{this.model.keyModels.firstObject.canEdit}}
/> @hasConfig={{this.model.hasConfig}}
{{else}} />
<Toolbar />
<EmptyState @title="PKI not configured" @message={{this.notConfiguredMessage}}>
<LinkTo @route="configuration.create">
Configure PKI
</LinkTo>
</EmptyState>
{{/if}}

View File

@ -9,17 +9,16 @@
@isEngine={{true}} @isEngine={{true}}
/> />
{{#if this.model.hasConfig}} <PkiPaginatedList @listRoute="roles.index" @list={{this.model.roles}} @hasConfig={{this.model.hasConfig}}>
<Toolbar> <:actions>
<ToolbarActions> {{#if this.model.hasConfig}}
<ToolbarLink @type="add" @route="roles.create" data-test-pki-role-create-link> <ToolbarLink @type="add" @route="roles.create" data-test-pki-role-create-link>
Create role Create role
</ToolbarLink> </ToolbarLink>
</ToolbarActions> {{/if}}
</Toolbar> </:actions>
<:list as |roles|>
{{#if this.model.roles.length}} {{#each roles as |pkiRole|}}
{{#each this.model.roles as |pkiRole|}}
<LinkedBlock class="list-item-row" @params={{array "roles.role.details" pkiRole.id}} @linkPrefix={{this.mountPoint}}> <LinkedBlock class="list-item-row" @params={{array "roles.role.details" pkiRole.id}} @linkPrefix={{this.mountPoint}}>
<div class="level is-mobile"> <div class="level is-mobile">
<div class="level-left"> <div class="level-left">
@ -53,7 +52,8 @@
</div> </div>
</LinkedBlock> </LinkedBlock>
{{/each}} {{/each}}
{{else}} </:list>
<:empty>
<EmptyState @title="No roles yet"> <EmptyState @title="No roles yet">
<div> <div>
<p>When created, roles will be listed here. Create a role to start generating certificates.</p> <p>When created, roles will be listed here. Create a role to start generating certificates.</p>
@ -64,12 +64,12 @@
</div> </div>
</div> </div>
</EmptyState> </EmptyState>
{{/if}} </:empty>
{{else}} <:configure>
<Toolbar /> <EmptyState @title="PKI not configured" @message={{this.notConfiguredMessage}}>
<EmptyState @title="PKI not configured" @message={{this.notConfiguredMessage}}> <LinkTo @route="configuration.create">
<LinkTo @route="configuration.create"> Configure PKI
Configure PKI </LinkTo>
</LinkTo> </EmptyState>
</EmptyState> </:configure>
{{/if}} </PkiPaginatedList>

View File

@ -70,28 +70,50 @@ module('Acceptance | pki configuration test', function (hooks) {
await authPage.login(this.pkiAdminToken); await authPage.login(this.pkiAdminToken);
await visit(`/vault/secrets/${this.mountPath}/pki/configuration`); await visit(`/vault/secrets/${this.mountPath}/pki/configuration`);
await click(SELECTORS.configuration.configureButton); await click(SELECTORS.configuration.configureButton);
assert.strictEqual(currentURL(), `/vault/secrets/${this.mountPath}/pki/configuration/create`); assert.strictEqual(
currentURL(),
`/vault/secrets/${this.mountPath}/pki/configuration/create`,
'goes to pki configure page'
);
await click(SELECTORS.configuration.generateRootOption); await click(SELECTORS.configuration.generateRootOption);
await fillIn(SELECTORS.configuration.typeField, 'exported'); await fillIn(SELECTORS.configuration.typeField, 'exported');
await fillIn(SELECTORS.configuration.generateRootCommonNameField, 'issuer-common-0'); await fillIn(SELECTORS.configuration.generateRootCommonNameField, 'issuer-common-0');
await fillIn(SELECTORS.configuration.generateRootIssuerNameField, 'issuer-0'); await fillIn(SELECTORS.configuration.generateRootIssuerNameField, 'issuer-0');
await click(SELECTORS.configuration.generateRootSave); await click(SELECTORS.configuration.generateRootSave);
await click(SELECTORS.configuration.doneButton); await click(SELECTORS.configuration.doneButton);
assert.strictEqual(currentURL(), `/vault/secrets/${this.mountPath}/pki/overview`); assert.strictEqual(
currentURL(),
`/vault/secrets/${this.mountPath}/pki/overview`,
'goes to overview page'
);
await click(SELECTORS.configTab); await click(SELECTORS.configTab);
assert.strictEqual(currentURL(), `/vault/secrets/${this.mountPath}/pki/configuration`); assert.strictEqual(
currentURL(),
`/vault/secrets/${this.mountPath}/pki/configuration`,
'goes to configuration page'
);
await click(SELECTORS.configuration.issuerLink); await click(SELECTORS.configuration.issuerLink);
assert.dom(SELECTORS.configuration.deleteAllIssuerModal).exists(); assert.dom(SELECTORS.configuration.deleteAllIssuerModal).exists();
await fillIn(SELECTORS.configuration.deleteAllIssuerInput, 'delete-all'); await fillIn(SELECTORS.configuration.deleteAllIssuerInput, 'delete-all');
await click(SELECTORS.configuration.deleteAllIssuerButton); await click(SELECTORS.configuration.deleteAllIssuerButton);
await isSettled(); await isSettled();
assert.dom(SELECTORS.configuration.deleteAllIssuerModal).doesNotExist(); assert
assert.strictEqual(currentURL(), `/vault/secrets/${this.mountPath}/pki/configuration`); .dom(SELECTORS.configuration.deleteAllIssuerModal)
.doesNotExist('delete all issuers modal closes');
assert.strictEqual(
currentURL(),
`/vault/secrets/${this.mountPath}/pki/configuration`,
'is still on configuration page'
);
await isSettled(); await isSettled();
await visit(`/vault/secrets/${this.mountPath}/pki/overview`); await visit(`/vault/secrets/${this.mountPath}/pki/overview`);
await waitUntil(() => currentURL() === `/vault/secrets/${this.mountPath}/pki/overview`); await waitUntil(() => currentURL() === `/vault/secrets/${this.mountPath}/pki/overview`);
await isSettled(); await isSettled();
assert.strictEqual(currentURL(), `/vault/secrets/${this.mountPath}/pki/overview`); assert.strictEqual(
currentURL(),
`/vault/secrets/${this.mountPath}/pki/overview`,
'goes to overview page'
);
assert assert
.dom(SELECTORS.emptyStateMessage) .dom(SELECTORS.emptyStateMessage)
.hasText( .hasText(

View File

@ -380,7 +380,7 @@ module('Acceptance | pki workflow', function (hooks) {
await visit(`/vault/secrets/${this.mountPath}/pki/overview`); await visit(`/vault/secrets/${this.mountPath}/pki/overview`);
await click(SELECTORS.issuersTab); await click(SELECTORS.issuersTab);
assert.dom('[data-test-serial-number="0"]').exists({ count: 1 }, 'displays serial number tag'); assert.dom('[data-test-serial-number="0"]').exists({ count: 1 }, 'displays serial number tag');
assert.dom('[data-test-common-name="0"]').exists({ count: 1 }, 'displays cert common name tag'); assert.dom('[data-test-common-name="0"]').doesNotExist('does not display cert common name tag');
}); });
test('details view renders correct number of info items', async function (assert) { test('details view renders correct number of info items', async function (assert) {

View File

@ -15,3 +15,9 @@ export const SELECTORS = {
revocationTime: '[data-test-row-value="Revocation time"]', revocationTime: '[data-test-row-value="Revocation time"]',
serialNumber: '[data-test-row-value="Serial number"]', serialNumber: '[data-test-row-value="Serial number"]',
}; };
export const STANDARD_META = {
total: 2,
currentPage: 1,
pageSize: 100,
};

View File

@ -0,0 +1,158 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'vault/tests/helpers';
import { setupEngine } from 'ember-engines/test-support';
import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { STANDARD_META } from 'vault/tests/helpers/pki';
module('Integration | Component | pki-paginated-list', function (hooks) {
setupRenderingTest(hooks);
setupEngine(hooks, 'pki');
hooks.beforeEach(function () {
this.store = this.owner.lookup('service:store');
this.secretMountPath = this.owner.lookup('service:secret-mount-path');
this.secretMountPath.currentPath = 'pki-test';
this.store.pushPayload('pki/key', {
modelName: 'pki/key',
data: {
key_id: '724862ff-6438-bad0-b598-77a6c7f4e934',
key_type: 'ec',
key_name: 'test-key',
},
});
this.store.pushPayload('pki/key', {
modelName: 'pki/key',
data: {
key_id: '9fdddf12-9ce3-0268-6b34-dc1553b00175',
key_type: 'rsa',
key_name: 'another-key',
},
});
// toArray to mimic what happens in lazyPaginatedQuery
const keyModels = this.store.peekAll('pki/key').toArray();
keyModels.meta = STANDARD_META;
this.list = keyModels;
const emptyList = this.store.peekAll('pki/foo');
emptyList.meta = {
meta: {
total: 0,
currentPage: 1,
pageSize: 100,
},
};
this.emptyList = emptyList;
});
test('it renders correctly with a list', async function (assert) {
this.set('hasConfig', null);
await render(
hbs`
<PkiPaginatedList @list={{this.list}} @hasConfig={{this.hasConfig}}>
<:list as |items|>
{{#each items as |item|}}
<div data-test-item={{item.keyId}}>{{item.keyName}}</div>
{{/each}}
</:list>
<:empty>
No items found
</:empty>
<:configure>
Not configured
</:configure>
</PkiPaginatedList>
`,
{ owner: this.engine }
);
assert.dom(this.element).doesNotContainText('Not configured', 'defaults to has config if not boolean');
assert.dom(this.element).doesNotContainText('No items found', 'does not render empty state');
assert.dom('[data-test-item]').exists({ count: 2 }, 'lists the items');
assert.dom('[data-test-item="724862ff-6438-bad0-b598-77a6c7f4e934"]').hasText('test-key');
assert.dom('[data-test-item="9fdddf12-9ce3-0268-6b34-dc1553b00175"]').hasText('another-key');
assert.dom('[data-test-pagination]').exists('shows pagination');
await this.set('hasConfig', false);
assert.dom(this.element).doesNotContainText('No items found', 'does not render empty state');
assert.dom(this.element).containsText('Not configured', 'shows configuration prompt');
assert.dom('[data-test-item]').doesNotExist('Does not show list items when not configured');
assert.dom('[data-test-pagination]').doesNotExist('hides pagination');
});
test('it renders correctly with an empty list', async function (assert) {
this.set('hasConfig', true);
await render(
hbs`
<PkiPaginatedList @list={{this.emptyList}} @hasConfig={{this.hasConfig}}>
<:list>
List item
</:list>
<:empty>
No items found
</:empty>
<:configure>
Not configured
</:configure>
</PkiPaginatedList>
`,
{ owner: this.engine }
);
assert.dom(this.element).doesNotContainText('list item', 'does not render list items if empty');
assert.dom(this.element).hasText('No items found', 'shows empty block');
assert.dom(this.element).doesNotContainText('Not configured', 'does not show configuration prompt');
assert.dom('[data-test-pagination]').doesNotExist('hides pagination');
await this.set('hasConfig', false);
assert.dom(this.element).doesNotContainText('list item', 'does not render list items if empty');
assert.dom(this.element).doesNotContainText('No items found', 'does not show empty state');
assert.dom(this.element).hasText('Not configured', 'shows configuration prompt');
assert.dom('[data-test-pagination]').doesNotExist('hides pagination');
});
test('it renders actions, description, pagination', async function (assert) {
this.set('hasConfig', true);
this.set('model', this.list);
await render(
hbs`
<PkiPaginatedList @list={{this.model}} @hasConfig={{this.hasConfig}}>
<:actions>
<div data-test-button>Action</div>
</:actions>
<:description>
Description goes here
</:description>
<:list>
List items
</:list>
<:empty>
No items found
</:empty>
<:configure>
Not configured
</:configure>
</PkiPaginatedList>
`,
{ owner: this.engine }
);
assert
.dom('[data-test-button]')
.includesText('Action', 'Renders actions in toolbar when list and config');
assert
.dom(this.element)
.includesText('Description goes here', 'renders description when list and config');
assert.dom('[data-test-pagination]').exists('shows pagination when list and config');
this.set('model', this.emptyList);
assert
.dom('[data-test-button]')
.hasText('Action', 'Renders actions in toolbar when empty list and config');
assert
.dom(this.element)
.doesNotIncludeText('Description goes here', 'hides description when empty list and config');
assert.dom('[data-test-pagination]').doesNotExist('hides pagination when empty list and config');
this.set('hasConfig', false);
assert.dom('[data-test-button]').hasText('Action', 'Renders actions in toolbar when no config');
assert.dom(this.element).doesNotIncludeText('Description goes here', 'hides description when no config');
assert.dom('[data-test-pagination]').doesNotExist('hides pagination when no config');
});
});

View File

@ -4,6 +4,7 @@ import { setupMirage } from 'ember-cli-mirage/test-support';
import { hbs } from 'ember-cli-htmlbars'; import { hbs } from 'ember-cli-htmlbars';
import { setupEngine } from 'ember-engines/test-support'; import { setupEngine } from 'ember-engines/test-support';
import { setupRenderingTest } from 'vault/tests/helpers'; import { setupRenderingTest } from 'vault/tests/helpers';
import { STANDARD_META } from 'vault/tests/helpers/pki';
/** /**
* this test is for the page component only. A separate test is written for the form rendered * this test is for the page component only. A separate test is written for the form rendered
@ -37,7 +38,9 @@ module('Integration | Component | page/pki-issuer-list', function (hooks) {
}, },
serialNumber: '74:2d:ed:f2:c4:3b:76:5e:6e:0d:f1:6a:c0:8b:6f:e3:3c:62:f9:03', serialNumber: '74:2d:ed:f2:c4:3b:76:5e:6e:0d:f1:6a:c0:8b:6f:e3:3c:62:f9:03',
}); });
this.issuers = this.store.peekAll('pki/issuer'); const issuers = this.store.peekAll('pki/issuer');
issuers.meta = STANDARD_META;
this.issuers = issuers;
await render(hbs`<Page::PkiIssuerList @issuers={{this.issuers}} @mountPoint={{this.engineId}} />`, { await render(hbs`<Page::PkiIssuerList @issuers={{this.issuers}} @mountPoint={{this.engineId}} />`, {
owner: this.engine, owner: this.engine,
@ -65,8 +68,9 @@ module('Integration | Component | page/pki-issuer-list', function (hooks) {
issuerName: 'issuer-1', issuerName: 'issuer-1',
isDefault: true, isDefault: true,
}); });
this.issuers = this.store.peekAll('pki/issuer'); const issuers = this.store.peekAll('pki/issuer');
issuers.meta = STANDARD_META;
this.issuers = issuers;
await render(hbs`<Page::PkiIssuerList @issuers={{this.issuers}} @mountPoint={{this.engineId}} />`, { await render(hbs`<Page::PkiIssuerList @issuers={{this.issuers}} @mountPoint={{this.engineId}} />`, {
owner: this.engine, owner: this.engine,
}); });

View File

@ -10,6 +10,7 @@ import { hbs } from 'ember-cli-htmlbars';
import { setupEngine } from 'ember-engines/test-support'; import { setupEngine } from 'ember-engines/test-support';
import { setupMirage } from 'ember-cli-mirage/test-support'; import { setupMirage } from 'ember-cli-mirage/test-support';
import { SELECTORS } from 'vault/tests/helpers/pki/page/pki-keys'; import { SELECTORS } from 'vault/tests/helpers/pki/page/pki-keys';
import { STANDARD_META } from 'vault/tests/helpers/pki';
module('Integration | Component | pki key list page', function (hooks) { module('Integration | Component | pki key list page', function (hooks) {
setupRenderingTest(hooks); setupRenderingTest(hooks);
@ -32,12 +33,20 @@ module('Integration | Component | pki key list page', function (hooks) {
key_type: 'rsa', key_type: 'rsa',
key_name: 'another-key', key_name: 'another-key',
}); });
this.keyModels = this.store.peekAll('pki/key'); const keyModels = this.store.peekAll('pki/key');
keyModels.meta = STANDARD_META;
this.keyModels = keyModels;
}); });
test('it renders empty state when no keys exist', async function (assert) { test('it renders empty state when no keys exist', async function (assert) {
assert.expect(3); assert.expect(3);
this.keyModels = []; this.keyModels = {
meta: {
total: 0,
currentPage: 1,
pageSize: 100,
},
};
await render( await render(
hbs` hbs`
<Page::PkiKeyList <Page::PkiKeyList

View File

@ -90,7 +90,15 @@ module('Unit | Service | store', function (hooks) {
store.constructResponse('data', { id: 1, pageFilter: 't', page: 1, size: 3, responsePath: 'data' }), store.constructResponse('data', { id: 1, pageFilter: 't', page: 1, size: 3, responsePath: 'data' }),
{ {
data: ['two', 'three', 'fifteen'], data: ['two', 'three', 'fifteen'],
meta: { currentPage: 1, lastPage: 2, nextPage: 2, prevPage: 1, total: 5, filteredTotal: 4 }, meta: {
currentPage: 1,
lastPage: 2,
nextPage: 2,
prevPage: 1,
total: 5,
filteredTotal: 4,
pageSize: 3,
},
}, },
'it returns filtered results' 'it returns filtered results'
); );
@ -132,6 +140,7 @@ module('Unit | Service | store', function (hooks) {
lastPage: 4, lastPage: 4,
total: 7, total: 7,
filteredTotal: 7, filteredTotal: 7,
pageSize: 2,
}, },
'returns correct meta values' 'returns correct meta values'
); );