mirror of https://github.com/status-im/consul.git
ui: Auth Methods List view (#9617)
* Create mock-api endpoints for auth-methods * Implement auth-method endpoints and model with tests * Create route and tab for auth-methods * Create auth-method list and type components with styles * Add JWT and OIDC svg logos to codebase * Add brand translations * Add SearchBar to Auth Methods * Add acceptance test for Auth Methods UI * Skip auth method repo test * Changes from review notes * Fixup auth-method modela and mock-data * Update SearhBar with rebased changes * Add filterBy source and sortBy max token ttl * Update to SortBy MethodName * Update UI acceptance tests * Update mock data DisplayNames * Skip repo test * Fix to breaking serializer test * Implement auth-method endpoints and model with tests * Add acceptance test for Auth Methods UI * Update SearhBar with rebased changes * Add filterBy source and sortBy max token ttl * Update to SortBy MethodName * Update UI acceptance tests * Update mock data DisplayNames * Fix to breaking serializer test * Update class for search * Add auth-methods link to sidebar * Fixup PR review notes * Fixup review notes * Only show OIDC filter with enterprise * Update conditionals for MaxTokenTTL & TokenLocality * Refactor
This commit is contained in:
parent
61fd873f24
commit
1507dd8ab3
|
@ -0,0 +1,28 @@
|
|||
import Adapter from './application';
|
||||
|
||||
export default class AuthMethodAdapter extends Adapter {
|
||||
requestForQuery(request, { dc, ns, index, id }) {
|
||||
return request`
|
||||
GET /v1/acl/auth-methods?${{ dc }}
|
||||
|
||||
${{
|
||||
...this.formatNspace(ns),
|
||||
index,
|
||||
}}
|
||||
`;
|
||||
}
|
||||
|
||||
requestForQueryRecord(request, { dc, ns, index, id }) {
|
||||
if (typeof id === 'undefined') {
|
||||
throw new Error('You must specify an id');
|
||||
}
|
||||
return request`
|
||||
GET /v1/acl/auth-method/${id}?${{ dc }}
|
||||
|
||||
${{
|
||||
...this.formatNspace(ns),
|
||||
index,
|
||||
}}
|
||||
`;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
.consul-auth-method-list ul {
|
||||
.consul-auth-method-type {
|
||||
@extend %pill-200, %frame-gray-600;
|
||||
}
|
||||
.locality::before {
|
||||
@extend %with-public-default-mask, %as-pseudo;
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
<ListCollection
|
||||
class="consul-auth-method-list"
|
||||
@items={{@items}}
|
||||
as |item|>
|
||||
<BlockSlot @name="header">
|
||||
{{#if (not-eq item.DisplayName '')}}
|
||||
<p data-test-auth-method>{{item.DisplayName}}</p>
|
||||
{{else}}
|
||||
<p data-test-auth-method>{{item.Name}}</p>
|
||||
{{/if}}
|
||||
</BlockSlot>
|
||||
<BlockSlot @name="details">
|
||||
<Consul::AuthMethod::Type @item={{item}} />
|
||||
{{#if (not-eq item.DisplayName '')}}
|
||||
<span data-test-display-name>{{item.Name}}</span>
|
||||
{{/if}}
|
||||
{{#if (eq item.TokenLocality 'global')}}
|
||||
<span class="locality">creates global tokens</span>
|
||||
{{/if}}
|
||||
{{#if item.MaxTokenTTL}}
|
||||
<dl class="ttl">
|
||||
<dt>
|
||||
<Tooltip>
|
||||
Maximum Time to Live: the maximum life of any token created by this auth method
|
||||
</Tooltip>
|
||||
</dt>
|
||||
<dd>{{item.MaxTokenTTL}}</dd>
|
||||
</dl>
|
||||
{{/if}}
|
||||
</BlockSlot>
|
||||
</ListCollection>
|
|
@ -0,0 +1,7 @@
|
|||
export default (collection, text) => () => {
|
||||
return collection('.consul-auth-method-list [data-test-list-row]', {
|
||||
name: text('[data-test-auth-method]'),
|
||||
displayName: text('[data-test-display-name]'),
|
||||
type: text('[data-test-type]'),
|
||||
});
|
||||
};
|
|
@ -0,0 +1,148 @@
|
|||
<SearchBar
|
||||
class="consul-auth-method-search-bar"
|
||||
...attributes
|
||||
@filter={{@filter}}
|
||||
>
|
||||
<:status as |search|>
|
||||
|
||||
{{#let
|
||||
|
||||
(t (concat "components.consul.auth-method.search-bar." search.status.key ".name")
|
||||
default=(array
|
||||
(concat "common.search." search.status.key)
|
||||
(concat "common.consul." search.status.key)
|
||||
)
|
||||
)
|
||||
|
||||
(t (concat "components.consul.auth-method.search-bar." search.status.key ".options." search.status.value)
|
||||
default=(array
|
||||
(concat "common.search." search.status.value)
|
||||
(concat "common.consul." search.status.value)
|
||||
(concat "common.brand." search.status.value)
|
||||
)
|
||||
)
|
||||
|
||||
as |key value|}}
|
||||
<search.RemoveFilter
|
||||
aria-label={{t "common.ui.remove" item=(concat key " " value)}}
|
||||
>
|
||||
<dl>
|
||||
<dt>{{key}}</dt>
|
||||
<dd>{{value}}</dd>
|
||||
</dl>
|
||||
</search.RemoveFilter>
|
||||
{{/let}}
|
||||
|
||||
</:status>
|
||||
<:search as |search|>
|
||||
<search.Search
|
||||
@onsearch={{action @onsearch}}
|
||||
@value={{@search}}
|
||||
@placeholder={{t "common.search.search"}}
|
||||
>
|
||||
<search.Select
|
||||
class="type-search-properties"
|
||||
@position="right"
|
||||
@onchange={{action @filter.searchproperty.change}}
|
||||
@multiple={{true}}
|
||||
@required={{true}}
|
||||
as |components|>
|
||||
<BlockSlot @name="selected">
|
||||
<span>
|
||||
{{t "common.search.searchproperty"}}
|
||||
</span>
|
||||
</BlockSlot>
|
||||
<BlockSlot @name="options">
|
||||
{{#let components.Optgroup components.Option as |Optgroup Option|}}
|
||||
{{#each @filter.searchproperty.default as |prop|}}
|
||||
<Option @value={{prop}} @selected={{contains prop @filter.searchproperty.value}}>
|
||||
{{t (concat "common.consul." (lowercase prop))}}
|
||||
</Option>
|
||||
{{/each}}
|
||||
{{/let}}
|
||||
</BlockSlot>
|
||||
</search.Select>
|
||||
</search.Search>
|
||||
</:search>
|
||||
<:filter as |search|>
|
||||
<search.Select
|
||||
class="type-kind"
|
||||
@position="left"
|
||||
@onchange={{action @filter.kind.change}}
|
||||
@multiple={{true}}
|
||||
as |components|>
|
||||
<BlockSlot @name="selected">
|
||||
<span>
|
||||
{{t "components.consul.auth-method.search-bar.kind.name"}}
|
||||
</span>
|
||||
</BlockSlot>
|
||||
<BlockSlot @name="options">
|
||||
{{#let components.Optgroup components.Option as |Optgroup Option|}}
|
||||
<Option class="kubernetes" @value="kubernetes" @selected={{contains 'kubernetes' @filter.kind.value}}>Kubernetes</Option>
|
||||
<Option class="jwt" @value="jwt" @selected={{contains 'jwt' @filter.kind.value}}>JWT</Option>
|
||||
{{#if (env 'CONSUL_SSO_ENABLED')}}
|
||||
<Option class="oidc" @value="oidc" @selected={{contains 'oidc' @filter.kind.value}}>OIDC</Option>
|
||||
{{/if}}
|
||||
{{/let}}
|
||||
</BlockSlot>
|
||||
</search.Select>
|
||||
<search.Select
|
||||
class="type-locality"
|
||||
@position="left"
|
||||
@onchange={{action @filter.source.change}}
|
||||
@multiple={{true}}
|
||||
as |components|>
|
||||
<BlockSlot @name="selected">
|
||||
<span>
|
||||
{{t "components.consul.auth-method.search-bar.locality.name"}}
|
||||
</span>
|
||||
</BlockSlot>
|
||||
<BlockSlot @name="options">
|
||||
{{#let components.Optgroup components.Option as |Optgroup Option|}}
|
||||
{{#each (array "local" "global") as |option|}}
|
||||
<Option class="{{option}}" @value={{option}} @selected={{contains option @filter.types}}>
|
||||
{{t (concat "components.consul.auth-method.search-bar.locality.options." option)}}
|
||||
</Option>
|
||||
{{/each}}
|
||||
{{/let}}
|
||||
</BlockSlot>
|
||||
</search.Select>
|
||||
</:filter>
|
||||
<:sort as |search|>
|
||||
<search.Select
|
||||
class="type-sort"
|
||||
data-test-sort-control
|
||||
@position="right"
|
||||
@onchange={{action @sort.change}}
|
||||
@multiple={{false}}
|
||||
@required={{true}}
|
||||
as |components|>
|
||||
<BlockSlot @name="selected">
|
||||
<span>
|
||||
{{#let (from-entries (array
|
||||
(array "MethodName:asc" (t "common.sort.alpha.asc"))
|
||||
(array "MethodName:desc" (t "common.sort.alpha.desc"))
|
||||
(array "MaxTokenTTL:desc" (t "common.sort.duration.asc"))
|
||||
(array "MaxTokenTTL:asc" (t "common.sort.duration.desc"))
|
||||
))
|
||||
as |selectable|
|
||||
}}
|
||||
{{get selectable @sort.value}}
|
||||
{{/let}}
|
||||
</span>
|
||||
</BlockSlot>
|
||||
<BlockSlot @name="options">
|
||||
{{#let components.Optgroup components.Option as |Optgroup Option|}}
|
||||
<Optgroup @label={{t "common.ui.name"}}>
|
||||
<Option @value="MethodName:asc" @selected={{eq "MethodName:asc" @sort.value}}>{{t "common.sort.alpha.asc"}}</Option>
|
||||
<Option @value="MethodName:desc" @selected={{eq "MethodName:desc" @sort.value}}>{{t "common.sort.alpha.desc"}}</Option>
|
||||
</Optgroup>
|
||||
<Optgroup @label={{t "common.ui.maxttl"}}>
|
||||
<Option @value="MaxTokenTTL:desc" @selected={{eq "MaxTokenTTL:desc" @sort.value}}>{{t "common.sort.duration.asc"}}</Option>
|
||||
<Option @value="MaxTokenTTL:asc" @selected={{eq "MaxTokenTTL:asc" @sort.value}}>{{t "common.sort.duration.desc"}}</Option>
|
||||
</Optgroup>
|
||||
{{/let}}
|
||||
</BlockSlot>
|
||||
</search.Select>
|
||||
</:sort>
|
||||
</SearchBar>
|
|
@ -0,0 +1,3 @@
|
|||
<span class="consul-auth-method-type {{@item.Type}}" data-test-type={{@item.Type}}>
|
||||
{{t (concat "common.brand." @item.Type)}}
|
||||
</span>
|
|
@ -126,6 +126,9 @@
|
|||
<li data-test-main-nav-roles class={{if (is-href 'dc.acls.roles' @dc.Name) 'is-active'}}>
|
||||
<a href={{href-to 'dc.acls.roles' @dc.Name}}>Roles</a>
|
||||
</li>
|
||||
<li data-test-main-nav-auth-methods class={{if (is-href 'dc.acls.auth-methods' @dc.Name) 'is-active'}}>
|
||||
<a href={{href-to 'dc.acls.auth-methods' @dc.Name}}>Auth Methods</a>
|
||||
</li>
|
||||
</ul>
|
||||
{{/if}}
|
||||
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
export default {
|
||||
kind: {
|
||||
kubernetes: (item, value) => item.Type === value,
|
||||
jwt: (item, value) => item.Type === value,
|
||||
oidc: (item, value) => item.Type === value,
|
||||
},
|
||||
source: {
|
||||
local: (item, value) => item.TokenLocality === value,
|
||||
global: (item, value) => item.TokenLocality === value,
|
||||
},
|
||||
};
|
|
@ -99,7 +99,10 @@ export function initialize(container) {
|
|||
register(container, index, indexed);
|
||||
}
|
||||
}
|
||||
register(container, route, item);
|
||||
|
||||
if (typeof route !== 'undefined') {
|
||||
register(container, route, item);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
import Model, { attr } from '@ember-data/model';
|
||||
import { or } from '@ember/object/computed';
|
||||
|
||||
export const PRIMARY_KEY = 'uid';
|
||||
export const SLUG_KEY = 'Name';
|
||||
|
||||
export default class AuthMethod extends Model {
|
||||
@attr('string') uid;
|
||||
@attr('string') Name;
|
||||
|
||||
@attr('string') Datacenter;
|
||||
@attr('string') Namespace;
|
||||
@attr('string', { defaultValue: () => '' }) Description;
|
||||
@attr('string', { defaultValue: () => '' }) DisplayName;
|
||||
@attr('string', { defaultValue: () => 'local' }) TokenLocality;
|
||||
@attr('string') Type;
|
||||
@or('DisplayName', 'Name') MethodName;
|
||||
@attr() Config;
|
||||
@attr('string') MaxTokenTTL;
|
||||
@attr('number') CreateIndex;
|
||||
@attr('number') ModifyIndex;
|
||||
@attr() Datacenters; // string[]
|
||||
@attr() meta; // {}
|
||||
}
|
|
@ -149,6 +149,12 @@ export const routes = {
|
|||
_options: { path: '/create' },
|
||||
},
|
||||
},
|
||||
'auth-methods': {
|
||||
_options: { path: '/auth-methods' },
|
||||
show: {
|
||||
_options: { path: '/show' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
// Shows a datacenter picker. If you only have one
|
||||
|
|
|
@ -0,0 +1,38 @@
|
|||
import { inject as service } from '@ember/service';
|
||||
import Route from 'consul-ui/routing/route';
|
||||
import { hash } from 'rsvp';
|
||||
|
||||
export default class IndexRoute extends Route {
|
||||
@service('repository/auth-method') repo;
|
||||
|
||||
queryParams = {
|
||||
sortBy: 'sort',
|
||||
source: 'source',
|
||||
kind: 'kind',
|
||||
searchproperty: {
|
||||
as: 'searchproperty',
|
||||
empty: [['Name', 'DisplayName']],
|
||||
},
|
||||
search: {
|
||||
as: 'filter',
|
||||
replace: true,
|
||||
},
|
||||
};
|
||||
|
||||
model(params) {
|
||||
return hash({
|
||||
...this.repo.status({
|
||||
items: this.repo.findAllByDatacenter(
|
||||
this.modelFor('dc').dc.Name,
|
||||
this.modelFor('nspace').nspace.substr(1)
|
||||
),
|
||||
}),
|
||||
searchProperties: this.queryParams.searchproperty.empty[0],
|
||||
});
|
||||
}
|
||||
|
||||
setupController(controller, model) {
|
||||
super.setupController(...arguments);
|
||||
controller.setProperties(model);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
export default {
|
||||
Name: item => item.Name,
|
||||
DisplayName: item => item.DisplayName,
|
||||
};
|
|
@ -0,0 +1,7 @@
|
|||
import Serializer from './application';
|
||||
import { PRIMARY_KEY, SLUG_KEY } from 'consul-ui/models/auth-method';
|
||||
|
||||
export default class AuthMethodSerializer extends Serializer {
|
||||
primaryKey = PRIMARY_KEY;
|
||||
slugKey = SLUG_KEY;
|
||||
}
|
|
@ -10,12 +10,14 @@ import kv from 'consul-ui/filter/predicates/kv';
|
|||
import intention from 'consul-ui/filter/predicates/intention';
|
||||
import token from 'consul-ui/filter/predicates/token';
|
||||
import policy from 'consul-ui/filter/predicates/policy';
|
||||
import authMethod from 'consul-ui/filter/predicates/auth-method';
|
||||
|
||||
const predicates = {
|
||||
acl: andOr(acl),
|
||||
service: andOr(service),
|
||||
['service-instance']: andOr(serviceInstance),
|
||||
['health-check']: andOr(healthCheck),
|
||||
['auth-method']: andOr(authMethod),
|
||||
node: andOr(node),
|
||||
kv: andOr(kv),
|
||||
intention: andOr(intention),
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
import RepositoryService from 'consul-ui/services/repository';
|
||||
import statusFactory from 'consul-ui/utils/acls-status';
|
||||
import isValidServerErrorFactory from 'consul-ui/utils/http/acl/is-valid-server-error';
|
||||
import { PRIMARY_KEY, SLUG_KEY } from 'consul-ui/models/auth-method';
|
||||
|
||||
const isValidServerError = isValidServerErrorFactory();
|
||||
const status = statusFactory(isValidServerError, Promise);
|
||||
const MODEL_NAME = 'auth-method';
|
||||
|
||||
export default class AuthMethodService extends RepositoryService {
|
||||
getModelName() {
|
||||
return MODEL_NAME;
|
||||
}
|
||||
|
||||
getPrimaryKey() {
|
||||
return PRIMARY_KEY;
|
||||
}
|
||||
|
||||
getSlugKey() {
|
||||
return SLUG_KEY;
|
||||
}
|
||||
|
||||
status(obj) {
|
||||
return status(obj);
|
||||
}
|
||||
}
|
|
@ -13,6 +13,7 @@ import kv from 'consul-ui/search/predicates/kv';
|
|||
import token from 'consul-ui/search/predicates/token';
|
||||
import role from 'consul-ui/search/predicates/role';
|
||||
import policy from 'consul-ui/search/predicates/policy';
|
||||
import authMethod from 'consul-ui/search/predicates/auth-method';
|
||||
import nspace from 'consul-ui/search/predicates/nspace';
|
||||
|
||||
const predicates = {
|
||||
|
@ -21,6 +22,7 @@ const predicates = {
|
|||
['service-instance']: serviceInstance,
|
||||
['upstream-instance']: upstreamInstance,
|
||||
['health-check']: healthCheck,
|
||||
['auth-method']: authMethod,
|
||||
node: node,
|
||||
kv: kv,
|
||||
acl: acl,
|
||||
|
|
|
@ -9,6 +9,7 @@ import intention from 'consul-ui/sort/comparators/intention';
|
|||
import token from 'consul-ui/sort/comparators/token';
|
||||
import role from 'consul-ui/sort/comparators/role';
|
||||
import policy from 'consul-ui/sort/comparators/policy';
|
||||
import authMethod from 'consul-ui/sort/comparators/auth-method';
|
||||
import nspace from 'consul-ui/sort/comparators/nspace';
|
||||
import node from 'consul-ui/sort/comparators/node';
|
||||
|
||||
|
@ -32,6 +33,7 @@ const comparators = {
|
|||
['service-instance']: serviceInstance(options),
|
||||
['upstream-instance']: upstreamInstance(options),
|
||||
['health-check']: healthCheck(options),
|
||||
['auth-method']: authMethod(options),
|
||||
acl: acl(options),
|
||||
kv: kv(options),
|
||||
intention: intention(options),
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
export default ({ properties }) => (key = 'MethodName:asc') => {
|
||||
return properties(['MethodName', 'MaxTokenTTL'])(key);
|
||||
};
|
|
@ -77,6 +77,7 @@ $history-svg: url('data:image/svg+xml;charset=UTF-8,<svg viewBox="0 0 24 24" fil
|
|||
$info-circle-fill-color-svg: url('data:image/svg+xml;charset=UTF-8,<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12 2c5.514 0 10 4.486 10 10s-4.486 10-10 10S2 17.543 2 12 6.486 2 12 2zm1.429 10.014a1.555 1.555 0 0 0-.443-.985c-.286-.272-.6-.429-.986-.443h-1.429c-.385.028-.685.185-.985.443a1.457 1.457 0 0 0-.443.985h1.428V16.3c.029.386.158.714.443.986.286.285.6.443.986.443h1.429c.385 0 .685-.158.985-.443.286-.272.429-.6.443-.986H13.43V12v.014zM11 7.73a1.345 1.345 0 0 1-.4-1c0-.4.129-.743.4-1 .271-.258.6-.4 1-.4s.743.128 1 .4c.257.271.4.6.4 1s-.129.742-.4 1a1.433 1.433 0 0 1-1 .428c-.4 0-.743-.157-1-.428z" fill="%231563ff" fill-rule="evenodd"/></svg>');
|
||||
$info-circle-fill-svg: url('data:image/svg+xml;charset=UTF-8,<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M12 2c5.514 0 10 4.486 10 10s-4.486 10-10 10S2 17.543 2 12 6.486 2 12 2zm1.429 10.014a1.555 1.555 0 0 0-.443-.985c-.286-.272-.6-.429-.986-.443h-1.429c-.385.028-.685.185-.985.443a1.456 1.456 0 0 0-.443.985h1.428V16.3c.029.386.158.714.443.986.286.285.6.443.986.443h1.429c.385 0 .685-.158.985-.443.286-.272.429-.6.443-.986H13.43V12v.014zM11 7.73a1.345 1.345 0 0 1-.4-1c0-.4.129-.743.4-1 .271-.258.6-.4 1-.4s.743.128 1 .4c.257.271.4.6.4 1s-.129.742-.4 1a1.433 1.433 0 0 1-1 .428c-.4 0-.743-.157-1-.428z" fill="%23000"/></svg>');
|
||||
$info-circle-outline-svg: url('data:image/svg+xml;charset=UTF-8,<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M12 2c5.514 0 10 4.486 10 10s-4.486 10-10 10S2 17.543 2 12 6.486 2 12 2zm0 1.886c-4.486 0-8.143 3.628-8.143 8.114 0 4.486 3.657 8.143 8.143 8.143 4.486 0 8.143-3.643 8.143-8.143 0-4.5-3.657-8.129-8.143-8.129v.015zm1.429 8.128a1.555 1.555 0 0 0-.443-.985c-.286-.272-.6-.429-.986-.443h-1.429c-.385.028-.685.185-.985.443a1.456 1.456 0 0 0-.443.985h1.428V16.3c.029.386.158.714.443.986.286.285.6.443.986.443h1.429c.385 0 .685-.158.985-.443.286-.272.429-.6.443-.986H13.43V12v.014zM11 8.73a1.345 1.345 0 0 1-.4-1c0-.4.129-.743.4-1 .271-.258.6-.4 1-.4s.743.128 1 .4c.257.271.4.6.4 1s-.129.742-.4 1a1.433 1.433 0 0 1-1 .428c-.4 0-.743-.157-1-.428z" fill="%23000"/></svg>');
|
||||
$jwt-logo-svg: url('data:image/svg+xml;charset=UTF-8,<svg width="12" height="13" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M6.863 3.805V.667h-1.75v3.138l.875 1.202.875-1.202zM5.113 9.195v3.138h1.75V9.195l-.875-1.202-.875 1.202z" fill="%23fff"/><path fill-rule="evenodd" clip-rule="evenodd" d="M6.863 9.195l1.844 2.543L10.13 10.7 8.275 8.168l-1.412-.466v1.493zM5.113 3.805L3.27 1.262 1.847 2.3l1.855 2.532 1.411.466V3.805z" fill="%2300F2E6"/><path fill-rule="evenodd" clip-rule="evenodd" d="M3.702 4.832L.715 3.863.167 5.532l2.986.968 1.424-.455-.875-1.213zM7.4 6.955l.875 1.213 2.987.969.548-1.669L8.823 6.5 7.4 6.955z" fill="%2300B9F1"/><path fill-rule="evenodd" clip-rule="evenodd" d="M8.823 6.5l2.987-.968-.548-1.669-2.987.969L7.4 6.045l1.423.455zM3.153 6.5l-2.986.968.548 1.669 2.987-.969.875-1.213L3.153 6.5z" fill="%23D63AFF"/><path fill-rule="evenodd" clip-rule="evenodd" d="M3.702 8.168L1.847 10.7l1.423 1.038 1.843-2.543V7.702l-1.411.466zM8.275 4.832L10.13 2.3 8.707 1.262 6.863 3.805v1.493l1.412-.466z" fill="%23FB015B"/></svg>');
|
||||
$key-svg: url('data:image/svg+xml;charset=UTF-8,<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M21 18v3h-2.969L17 20v-2h-2v-2h-2l-4-4 3.052-3L21 18zM10 6L8 4 5.003 5 4 8l2 2 4-4zm-4.217 7.839L1.132 9.188l1.702-6.354 6.354-1.702 4.65 4.65-1.702 6.354-6.353 1.703z" fill="%23000"/></svg>');
|
||||
$kubernetes-logo-color-svg: url('data:image/svg+xml;charset=UTF-8,<svg width="21" height="20" xmlns="http://www.w3.org/2000/svg"><g stroke="%23FFF" fill="none"><path d="M10.21 1.002a1.241 1.241 0 0 0-.472.12L3.29 4.201a1.225 1.225 0 0 0-.667.83l-1.591 6.922a1.215 1.215 0 0 0 .238 1.035l4.463 5.55c.234.29.59.46.964.46l7.159-.002c.375 0 .73-.168.964-.459l4.462-5.55c.234-.292.322-.673.238-1.036L17.927 5.03a1.225 1.225 0 0 0-.667-.83l-6.45-3.08a1.242 1.242 0 0 0-.598-.12z" fill="%23326CE5"/><path d="M10.275 3.357c-.213 0-.386.192-.386.429v.11c.005.136.035.24.052.367.033.27.06.492.043.7a.421.421 0 0 1-.125.2l-.01.163a4.965 4.965 0 0 0-3.22 1.548 6.47 6.47 0 0 1-.138-.099c-.07.01-.139.03-.23-.022-.172-.117-.33-.277-.52-.47-.087-.093-.15-.181-.254-.27L5.4 5.944a.46.46 0 0 0-.269-.101.372.372 0 0 0-.307.136c-.133.167-.09.422.094.57l.006.003.08.065c.11.08.21.122.32.187.231.142.422.26.574.403.06.063.07.175.078.223l.123.11a4.995 4.995 0 0 0-.787 3.483l-.162.047c-.042.055-.103.141-.166.167-.198.063-.422.086-.692.114-.126.01-.236.004-.37.03-.03.005-.07.016-.103.023l-.003.001-.006.002c-.228.055-.374.264-.327.47.047.206.27.331.498.282h.006c.003-.001.005-.003.008-.003l.1-.022c.131-.036.227-.088.346-.133.255-.092.467-.168.673-.198.086-.007.177.053.222.078l.168-.029a5.023 5.023 0 0 0 2.226 2.78l-.07.168c.025.065.053.154.034.218-.075.195-.203.4-.35.628-.07.106-.142.188-.206.309l-.05.104c-.099.212-.026.456.165.548.191.092.43-.005.532-.218h.001v-.001c.015-.03.036-.07.048-.098.055-.126.073-.233.111-.354.102-.257.159-.526.3-.694.038-.046.1-.063.166-.08l.087-.159a4.987 4.987 0 0 0 3.562.01l.083.148c.066.021.138.032.197.12.105.179.177.391.265.648.038.121.057.229.112.354.012.029.033.069.048.099.102.213.341.311.533.219.19-.092.264-.337.164-.549l-.05-.104c-.064-.12-.136-.202-.207-.307-.146-.23-.267-.419-.342-.613-.032-.1.005-.163.03-.228-.015-.017-.047-.111-.065-.156a5.023 5.023 0 0 0 2.225-2.8l.165.03c.058-.039.112-.088.216-.08.206.03.418.106.673.198.12.045.215.098.347.133.028.008.068.015.1.022l.007.002.006.001c.229.05.45-.076.498-.282.047-.206-.1-.415-.327-.47l-.112-.027c-.134-.025-.243-.019-.37-.03-.27-.027-.494-.05-.692-.113-.081-.031-.139-.128-.167-.167l-.156-.046a4.997 4.997 0 0 0-.804-3.474l.137-.123c.006-.069.001-.142.073-.218.151-.143.343-.261.574-.404.11-.064.21-.106.32-.187.025-.018.06-.047.086-.068.185-.148.227-.403.094-.57-.133-.166-.39-.182-.575-.034-.027.02-.062.048-.086.068-.104.09-.168.178-.255.27-.19.194-.348.355-.52.471-.075.044-.185.029-.235.026l-.146.104A5.059 5.059 0 0 0 10.7 5.328a9.325 9.325 0 0 1-.009-.172c-.05-.048-.11-.09-.126-.193-.017-.208.011-.43.044-.7.018-.126.047-.23.053-.367l-.001-.11c0-.237-.173-.429-.386-.429zM9.79 6.351l-.114 2.025-.009.004a.34.34 0 0 1-.54.26l-.003.002-1.66-1.177A3.976 3.976 0 0 1 9.79 6.351zm.968 0a4.01 4.01 0 0 1 2.313 1.115l-1.65 1.17-.006-.003a.34.34 0 0 1-.54-.26h-.003L10.76 6.35zm-3.896 1.87l1.516 1.357-.002.008a.34.34 0 0 1-.134.585l-.001.006-1.944.561a3.975 3.975 0 0 1 .565-2.516zm6.813.001a4.025 4.025 0 0 1 .582 2.51l-1.954-.563-.001-.008a.34.34 0 0 1-.134-.585v-.004l1.507-1.35zm-3.712 1.46h.62l.387.483-.139.602-.557.268-.56-.269-.138-.602.387-.482zm1.99 1.652a.339.339 0 0 1 .08.005l.002-.004 2.01.34a3.98 3.98 0 0 1-1.609 2.022l-.78-1.885.002-.003a.34.34 0 0 1 .296-.475zm-3.375.008a.34.34 0 0 1 .308.474l.005.007-.772 1.866a3.997 3.997 0 0 1-1.604-2.007l1.993-.339.003.005a.345.345 0 0 1 .067-.006zm1.683.817a.338.338 0 0 1 .312.179h.008l.982 1.775a3.991 3.991 0 0 1-2.57-.002l.979-1.772h.001a.34.34 0 0 1 .288-.18z" stroke-width=".25" fill="%23FFF"/></g></svg>');
|
||||
$layers-svg: url('data:image/svg+xml;charset=UTF-8,<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M12.815 18.637l-.101.029c-.169.043-.34.064-.51.064H12.103l-.09-.007a1.74 1.74 0 01-.319-.054l-.061-.01-.163-.058c-.108-.043-3.833-1.667-6.675-2.907l-2.559 1.328a.422.422 0 00-.184.149.329.329 0 00-.044.256v.01c.011.042.03.08.055.117.006.008.006.018.013.026l.007.007c.018.021.042.038.066.056.025.021.049.041.078.057.002 0 .005.004.007.005.128.059 9.629 4.205 9.76 4.258.01.005.02.004.03.007a.506.506 0 00.315.012l.023-.008c.03-.01.062-.015.09-.03l9.319-4.833c.015-.006.023-.019.036-.026a.461.461 0 00.125-.115c.008-.011.02-.018.026-.03.007-.01.006-.023.012-.034a.368.368 0 00.029-.151.323.323 0 00-.013-.072.343.343 0 00-.03-.076c-.006-.011-.006-.023-.013-.034-.008-.01-.02-.018-.03-.028a.445.445 0 00-.134-.106c-.014-.007-.023-.018-.037-.024l-2.479-1.082-6.128 3.178a1.403 1.403 0 01-.321.126z" fill="%23000"/><path fill-rule="evenodd" clip-rule="evenodd" d="M12.819 13.873l-.095.028a2.104 2.104 0 01-.531.067h-.073l-.106-.008c-.1-.006-.22-.027-.342-.06l-.056-.01-.145-.05a2798 2798 0 01-6.674-2.909l-2.56 1.33a.423.423 0 00-.184.148.33.33 0 00-.044.257v.01c.011.041.03.079.055.116.006.008.006.017.013.026.002.004.005.005.007.007.018.021.042.039.066.057.025.02.049.041.078.056l.007.005c.128.06 9.629 4.206 9.76 4.26.01.003.02.002.03.006a.506.506 0 00.135.028l.033.001a.542.542 0 00.17-.026c.03-.01.062-.014.09-.03l9.319-4.832c.015-.008.023-.019.036-.027a.489.489 0 00.125-.114c.008-.012.02-.019.026-.03.007-.011.006-.023.012-.035a.362.362 0 00.029-.15.311.311 0 00-.013-.072.333.333 0 00-.03-.076c-.006-.012-.006-.023-.013-.034-.008-.011-.02-.018-.03-.028a.412.412 0 00-.134-.107c-.014-.007-.023-.017-.037-.024l-2.478-1.082-6.129 3.178a1.462 1.462 0 01-.317.124z" fill="%23000"/><path fill-rule="evenodd" clip-rule="evenodd" d="M12.029 2.045c-.018-.008-.036-.01-.053-.015A.45.45 0 0011.79 2a.44.44 0 00-.182.04c-.01.005-.023.005-.034.01-.123.064-8.853 4.87-9.338 5.141a.433.433 0 00-.184.157.37.37 0 00-.044.273v.01c.011.045.03.085.055.124.006.01.006.02.013.029l.007.006c.018.024.042.041.066.06.025.022.049.044.078.06a5453.121 5453.121 0 009.766 4.528c.011.004.021.004.031.007a.482.482 0 00.315.012l.023-.008c.03-.01.062-.016.09-.033l9.319-5.13c.015-.008.023-.02.036-.029a.464.464 0 00.125-.122c.008-.011.02-.019.026-.032.007-.011.006-.023.012-.035A.435.435 0 0022 6.907a.345.345 0 00-.013-.076.367.367 0 00-.03-.081c-.006-.012-.006-.024-.013-.035-.008-.013-.02-.02-.03-.03a.457.457 0 00-.134-.113c-.014-.008-.023-.02-.037-.026l-9.714-4.501z" fill="%23000"/></svg>');
|
||||
|
@ -128,6 +129,7 @@ $nomad-logo-color-svg: url('data:image/svg+xml;charset=UTF-8,<svg viewBox="0 0 1
|
|||
$notification-disabled-svg: url('data:image/svg+xml;charset=UTF-8,<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M20 18.19L7.84 5.64 5.27 2.99 4 4.26l2.8 2.8v.01c-.52.99-.8 2.16-.8 3.42v5l-2 2v1h13.73l2 2L21 19.22l-1-1.03zm-8 3.31c1.11 0 2-.89 2-2h-4c0 1.11.89 2 2 2zm6-7.32V10.5c0-3.08-1.64-5.64-4.5-6.32V3.5c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68c-.15.03-.29.08-.42.12-.1.03-.2.07-.3.11h-.01c-.01 0-.01 0-.02.01-.23.09-.46.2-.68.31 0 0-.01 0-.01.01L18 14.18z" fill="%23000"/></svg>');
|
||||
$notification-fill-svg: url('data:image/svg+xml;charset=UTF-8,<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M12 21.5c1.1 0 2-.9 2-2h-4a2 2 0 0 0 2 2zm6-6v-5c0-3.07-1.64-5.64-4.5-6.32V3.5c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 4.86 6 7.42 6 10.5v5l-2 2v1h16v-1l-2-2z" fill="%23000"/></svg>');
|
||||
$notification-outline-svg: url('data:image/svg+xml;charset=UTF-8,<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M12 21.5c1.1 0 2-.9 2-2h-4c0 1.1.9 2 2 2zm6-6v-5c0-3.07-1.63-5.64-4.5-6.32V3.5c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.64 4.86 6 7.42 6 10.5v5l-2 2v1h16v-1l-2-2zm-2 1H8v-6C8 8.02 9.51 6 12 6s4 2.02 4 4.5v6z" fill="%23000"/></svg>');
|
||||
$oidc-logo-svg: url('data:image/svg+xml;charset=UTF-8,<svg viewbox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12.833 8.375l-.291-2.625-.846.554c-.788-.496-1.78-.846-2.888-1.02 0 0-.554-.117-1.283-.117-.73 0-1.4.087-1.4.087-2.83.35-4.958 1.954-4.958 3.88 0 1.983 2.187 3.616 5.541 3.908v-1.138c-2.304-.32-3.762-1.4-3.762-2.77 0-1.284 1.341-2.363 3.179-2.713 0 0 1.43-.321 2.683.058.613.146 1.167.35 1.634.642l-1.109.67 3.5.584z" fill="%239E9E9E"/><path d="M6.708 2.833v10.209l1.75-.875V1.958l-1.75.875z" fill="%23000"/><path d="M6.708 2.833v10.209l1.75-.875V1.958l-1.75.875z" fill="%23FF9800"/></svg>');
|
||||
$outline-svg: url('data:image/svg+xml;charset=UTF-8,<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M3 4c0-1.1.9-2 2-2h14c1.1 0 2 .9 2 2v16c0 1.1-.9 2-2 2H5c-1.1 0-2-.9-2-2V4zm2 0v16h14V4H5zm5.996 7.006v2h3v-2h-3zM15 15v2h-4v-2h4zm-4-6h6V7h-6v2zm-4 3.006a1 1 0 0 1 .998-.998 1 1 0 0 1 .998.998 1 1 0 0 1-.998.998A1 1 0 0 1 7 12.006zM7.998 15a1 1 0 0 0-.998.998 1 1 0 0 0 .998.998 1 1 0 0 0 .998-.998A1 1 0 0 0 7.998 15zM7 7.998A1 1 0 0 1 7.998 7a1 1 0 0 1 .998.998 1 1 0 0 1-.998.998A1 1 0 0 1 7 7.998z" fill="%23000"/></svg>');
|
||||
$page-outline-svg: url('data:image/svg+xml;charset=UTF-8,<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M19 2c1.05 0 1.918.82 1.994 1.851L21 4v16c0 1.05-.82 1.918-1.851 1.994L19 22H5c-1.05 0-1.918-.82-1.994-1.851L3 20V4c0-1.05.82-1.918 1.851-1.994L5 2h14zm0 2H5v16h14V4zM7.952 15.004a1 1 0 0 1 .998.998 1 1 0 0 1-.998.998 1 1 0 0 1-.998-.998 1 1 0 0 1 .998-.998zM15.944 15v2h-6v-2h6zm-2-4v2h-4v-2h4zm-5.992 0a1 1 0 0 1 .998.998 1 1 0 0 1-.998.998 1 1 0 0 1-.998-.998A1 1 0 0 1 7.952 11zm8.992-4v2h-7V7h7zm-8.992.004a1 1 0 0 1 .998.998A1 1 0 0 1 7.952 9a1 1 0 0 1-.998-.998 1 1 0 0 1 .998-.998z" fill="%23000"/></svg>');
|
||||
$partner-svg: url('data:image/svg+xml;charset=UTF-8,<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M8.478 8.41l2.556-2.339H8.341L5 8.075H2v6.99h4.572l2.441 2.184c.913.779 1.473.779 2.028.54.61-.262.959-.699.959-.699l.613.517c.587.54 1.537.294 2.027-.192.745-.741.724-1.188.724-1.188l.517.43c.39.233.988-.025 1.187-.228.44-.446.54-.944.064-1.395l-4.124-3.669c-.545-.471-.562-.464-1.008-.094l-.491.445c-1.02.765-2.34.775-3.168-.128a2.25 2.25 0 0 1 .137-3.177zm7.813-2.045l-.707-.294H12.9a1 1 0 0 0-.675.263L9.153 9.145l-.005.006-.004.007a1.242 1.242 0 0 0-.066 1.749c.397.434 1.231.55 1.753.084l.007-.003.006-.003 2.497-2.287a.5.5 0 1 1 .675.737l-.816.747 4.797 4.216H22V8.07h-4.003L16.29 6.365z" fill="%23000"/></svg>');
|
||||
|
|
|
@ -778,6 +778,16 @@
|
|||
mask-image: $info-circle-outline-svg;
|
||||
}
|
||||
|
||||
%with-jwt-logo-icon {
|
||||
@extend %with-icon;
|
||||
background-image: $jwt-logo-svg;
|
||||
}
|
||||
%with-jwt-logo-mask {
|
||||
@extend %with-mask;
|
||||
-webkit-mask-image: $jwt-logo-svg;
|
||||
mask-image: $jwt-logo-svg;
|
||||
}
|
||||
|
||||
%with-key-icon {
|
||||
@extend %with-icon;
|
||||
background-image: $key-svg;
|
||||
|
@ -1316,6 +1326,16 @@
|
|||
mask-image: $notification-outline-svg;
|
||||
}
|
||||
|
||||
%with-oidc-logo-icon {
|
||||
@extend %with-icon;
|
||||
background-image: $oidc-logo-svg;
|
||||
}
|
||||
%with-oidc-logo-mask {
|
||||
@extend %with-mask;
|
||||
-webkit-mask-image: $oidc-logo-svg;
|
||||
mask-image: $oidc-logo-svg;
|
||||
}
|
||||
|
||||
%with-outline-icon {
|
||||
@extend %with-icon;
|
||||
background-image: $outline-svg;
|
||||
|
|
|
@ -74,6 +74,7 @@
|
|||
@import 'consul-ui/components/consul/kind';
|
||||
@import 'consul-ui/components/consul/intention';
|
||||
@import 'consul-ui/components/consul/lock-session/form';
|
||||
@import 'consul-ui/components/consul/auth-method';
|
||||
|
||||
@import 'consul-ui/components/role-selector';
|
||||
@import 'consul-ui/components/topology-metrics';
|
||||
|
|
|
@ -33,3 +33,9 @@ span.policy-service-identity::before {
|
|||
%pill.leader::before {
|
||||
@extend %with-star-outline-mask, %as-pseudo;
|
||||
}
|
||||
%pill.jwt::before {
|
||||
@extend %with-jwt-logo-icon, %as-pseudo;
|
||||
}
|
||||
%pill.oidc::before {
|
||||
@extend %with-oidc-logo-icon, %as-pseudo;
|
||||
}
|
||||
|
|
|
@ -49,6 +49,12 @@
|
|||
%popover-select .kubernetes button::before {
|
||||
@extend %with-logo-kubernetes-color-icon, %as-pseudo;
|
||||
}
|
||||
%popover-select .jwt button::before {
|
||||
@extend %with-jwt-logo-icon, %as-pseudo;
|
||||
}
|
||||
%popover-select .oidc button::before {
|
||||
@extend %with-oidc-logo-icon, %as-pseudo;
|
||||
}
|
||||
%popover-select .consul button::before {
|
||||
@extend %with-logo-consul-color-icon, %as-pseudo;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,102 @@
|
|||
{{#if isAuthorized }}
|
||||
{{page-title 'Auth Methods'}}
|
||||
{{else}}
|
||||
{{page-title 'Access Controls'}}
|
||||
{{/if}}
|
||||
|
||||
{{#let
|
||||
|
||||
(hash
|
||||
value=(or sortBy "MethodName:asc")
|
||||
change=(action (mut sortBy) value="target.selected")
|
||||
)
|
||||
|
||||
(hash
|
||||
kind=(hash
|
||||
value=(if kind (split kind ',') undefined)
|
||||
change=(action (mut kind) value="target.selectedItems")
|
||||
)
|
||||
source=(hash
|
||||
value=(if source (split source ',') undefined)
|
||||
change=(action (mut source) value="target.selectedItems")
|
||||
)
|
||||
searchproperty=(hash
|
||||
value=(if (not-eq searchproperty undefined)
|
||||
(split searchproperty ',')
|
||||
searchProperties
|
||||
)
|
||||
change=(action (mut searchproperty) value="target.selectedItems")
|
||||
default=searchProperties
|
||||
)
|
||||
)
|
||||
|
||||
items
|
||||
|
||||
as |sort filters items|}}
|
||||
|
||||
|
||||
<AppView
|
||||
@authorized={{isAuthorized}}
|
||||
@enabled={{isEnabled}}
|
||||
>
|
||||
<BlockSlot @name="header">
|
||||
<h1>
|
||||
Access Controls
|
||||
</h1>
|
||||
</BlockSlot>
|
||||
<BlockSlot @name="toolbar">
|
||||
{{#if (gt items.length 0)}}
|
||||
<Consul::AuthMethod::SearchBar
|
||||
@search={{search}}
|
||||
@onsearch={{action (mut search) value="target.value"}}
|
||||
@sort={{sort}}
|
||||
@filter={{filters}}
|
||||
/>
|
||||
{{/if}}
|
||||
</BlockSlot>
|
||||
<BlockSlot @name="content">
|
||||
<DataCollection
|
||||
@type="auth-method"
|
||||
@sort={{sort.value}}
|
||||
@filters={{filters}}
|
||||
@search={{search}}
|
||||
@items={{items}}
|
||||
as |collection|>
|
||||
<collection.Collection>
|
||||
<Consul::AuthMethod::List @items={{collection.items}} />
|
||||
</collection.Collection>
|
||||
<collection.Empty>
|
||||
<EmptyState @allowLogin={{true}}>
|
||||
<BlockSlot @name="header">
|
||||
<h2>
|
||||
{{#if (gt items.length 0)}}
|
||||
No auth methods found
|
||||
{{else}}
|
||||
Welcome to Auth Methods
|
||||
{{/if}}
|
||||
</h2>
|
||||
</BlockSlot>
|
||||
<BlockSlot @name="body">
|
||||
<p>
|
||||
{{#if (gt items.length 0)}}
|
||||
No auth methods where found matching that search, or you may not have access to view the auth methods you are searching for.
|
||||
{{else}}
|
||||
There don't seem to be any auth methods, or you may not have access to view auth methods yet.
|
||||
{{/if}}
|
||||
</p>
|
||||
</BlockSlot>
|
||||
<BlockSlot @name="actions">
|
||||
<li class="docs-link">
|
||||
<a href="{{env 'CONSUL_DOCS_URL'}}/security/acl/auth-methods" rel="noopener noreferrer" target="_blank">Documentation on auth methods</a>
|
||||
</li>
|
||||
<li class="learn-link">
|
||||
<a href="{{env 'CONSUL_DOCS_API_URL'}}/acl/auth-methods.html" rel="noopener noreferrer" target="_blank">Read the API Docs</a>
|
||||
</li>
|
||||
</BlockSlot>
|
||||
</EmptyState>
|
||||
</collection.Empty>
|
||||
</DataCollection>
|
||||
</BlockSlot>
|
||||
</AppView>
|
||||
{{/let}}
|
||||
|
|
@ -97,7 +97,7 @@ as |sort filters items|}}
|
|||
<a href="{{env 'CONSUL_DOCS_URL'}}/commands/acl/role" rel="noopener noreferrer" target="_blank">Documentation on roles</a>
|
||||
</li>
|
||||
<li class="learn-link">
|
||||
<a href="{{env 'CONSUL_DOCS_API_URL'}}/acl/roles.html" rel="noopener noreferrer" target="_blank">Read the guide</a>
|
||||
<a href="{{env 'CONSUL_DOCS_API_URL'}}/acl/roles.html" rel="noopener noreferrer" target="_blank">Read the API Docs</a>
|
||||
</li>
|
||||
</BlockSlot>
|
||||
</EmptyState>
|
||||
|
|
|
@ -0,0 +1,44 @@
|
|||
${
|
||||
[1].map(() => {
|
||||
const type = `${fake.helpers.randomize(['kubernetes', 'jwt', 'oidc'])}`;
|
||||
const fakeIP = `${fake.internet.ip()}`;
|
||||
let config = {};
|
||||
switch(type) {
|
||||
case 'kubernetes':
|
||||
config = {
|
||||
Host: `https://${fake.internet.ip()}:8443`,
|
||||
CACert: `-----BEGIN CERTIFICATE-----${fake.internet.password(1357)}-----END CERTIFICATE-----`,
|
||||
ServiceAccountJWT: `eyJhbGciOiJ${fake.internet.password(25)}.eyJ${fake.internet.password(61)}.${fake.internet.password(32)}`
|
||||
};
|
||||
break;
|
||||
case 'oidc':
|
||||
config = {
|
||||
OIDCDiscoveryURL: `https://${fake.internet.ip()}:8443`,
|
||||
};
|
||||
break;
|
||||
case 'jwt':
|
||||
config = {
|
||||
JWTValidationPubKeys: `-----BEGIN CERTIFICATE-----${fake.internet.password(1357)}-----END CERTIFICATE-----`,
|
||||
JWKSURL: `https://${fake.internet.ip()}:8443`,
|
||||
OIDCDiscoveryURL: `https://${fake.internet.ip()}:8443`,
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
return `{
|
||||
"Name": "${location.pathname.get(3)}",
|
||||
"Namespace": "${
|
||||
typeof location.search.ns !== 'undefined' ? location.search.ns :
|
||||
typeof http.body.Namespace !== 'undefined' ? http.body.Namespace : 'default'
|
||||
}",
|
||||
"Type": "${type}",
|
||||
"Description": "${fake.lorem.sentence()}",
|
||||
"DisplayName": "${fake.hacker.noun()}",
|
||||
"MaxTokenTTL": "${fake.random.number({min: 0, max: 60})}m${fake.random.number({min: 0, max: 60})}s",
|
||||
"TokenLocality": "${fake.helpers.randomize(['local', 'global', ''])}",
|
||||
"Config": ${JSON.stringify(config)},
|
||||
"CreateIndex": ${fake.random.number()},
|
||||
"ModifyIndex": 10
|
||||
}`
|
||||
})
|
||||
}
|
|
@ -0,0 +1,42 @@
|
|||
[
|
||||
${
|
||||
range(
|
||||
env(
|
||||
'CONSUL_AUTH_METHOD_COUNT',
|
||||
Math.floor(
|
||||
(
|
||||
Math.random() * env('CONSUL_AUTH_METHOD_MAX', 10)
|
||||
) + parseInt(env('CONSUL_AUTH_METHOD_MIN', 1))
|
||||
)
|
||||
)
|
||||
).map(
|
||||
function(item, i) {
|
||||
return `
|
||||
{
|
||||
"Name": "${fake.hacker.noun()}-${i}",
|
||||
${typeof location.search.ns !== 'undefined' ? `
|
||||
"Namespace": "${location.search.ns}",
|
||||
` : ``}
|
||||
"Type": "${fake.helpers.randomize(['kubernetes', 'jwt', 'oidc'])}",
|
||||
"Description": "${fake.lorem.sentence()}",
|
||||
${i%2 ? `
|
||||
"DisplayName": "${fake.hacker.noun()}-${i}",
|
||||
` : `
|
||||
"DisplayName": "",
|
||||
`}
|
||||
${i%2 ? `
|
||||
"MaxTokenTTL": "${fake.random.number({min: 0, max: 60})}m${fake.random.number({min: 0, max: 60})}s",
|
||||
` : `
|
||||
`}
|
||||
${i%2 ? `
|
||||
"TokenLocality": "${fake.helpers.randomize(['local', 'global', ''])}",
|
||||
` : `
|
||||
`}
|
||||
"CreateIndex": ${fake.random.number()},
|
||||
"ModifyIndex": 10
|
||||
}
|
||||
`
|
||||
}
|
||||
)
|
||||
}
|
||||
]
|
|
@ -0,0 +1,48 @@
|
|||
@setupApplicationTest
|
||||
Feature: dc / acls / auth-methods / index: ACL Auth Methods List
|
||||
|
||||
Scenario:
|
||||
Given 1 datacenter model with the value "dc-1"
|
||||
And 3 authMethod models
|
||||
When I visit the authMethods page for yaml
|
||||
---
|
||||
dc: dc-1
|
||||
---
|
||||
Then the url should be /dc-1/acls/auth-methods
|
||||
Then I see 3 authMethod models
|
||||
And the title should be "Auth Methods - Consul"
|
||||
Scenario: Searching the Auth Methods
|
||||
Given 1 datacenter model with the value "dc-1"
|
||||
And 3 authMethod models from yaml
|
||||
---
|
||||
- Name: kube
|
||||
DisplayName: minikube
|
||||
- Name: agent
|
||||
DisplayName: ''
|
||||
- Name: node
|
||||
DisplayName: mininode
|
||||
---
|
||||
When I visit the authMethods page for yaml
|
||||
---
|
||||
dc: dc-1
|
||||
---
|
||||
Then the url should be /dc-1/acls/auth-methods
|
||||
Then I see 3 authMethod models
|
||||
Then I fill in with yaml
|
||||
---
|
||||
s: kube
|
||||
---
|
||||
And I see 1 authMethod model
|
||||
And I see 1 authMethod model with the name "minikube"
|
||||
Then I fill in with yaml
|
||||
---
|
||||
s: agent
|
||||
---
|
||||
And I see 1 authMethod model
|
||||
And I see 1 authMethod model with the name "agent"
|
||||
Then I fill in with yaml
|
||||
---
|
||||
s: ode
|
||||
---
|
||||
And I see 1 authMethod model
|
||||
And I see 1 authMethod model with the name "mininode"
|
|
@ -0,0 +1,39 @@
|
|||
@setupApplicationTest
|
||||
Feature: dc / acls / auth-methods / sorting
|
||||
Scenario: Sorting Auth Methods
|
||||
Given 1 datacenter model with the value "dc-1"
|
||||
And 4 authMethod models from yaml
|
||||
---
|
||||
- Name: "system-A"
|
||||
DisplayName: ''
|
||||
- Name: "system-D"
|
||||
DisplayName: ''
|
||||
- Name: "system-C"
|
||||
DisplayName: ''
|
||||
- Name: "system-B"
|
||||
DisplayName: ''
|
||||
---
|
||||
When I visit the authMethods page for yaml
|
||||
---
|
||||
dc: dc-1
|
||||
---
|
||||
Then the url should be /dc-1/acls/auth-methods
|
||||
Then I see 4 authMethod models
|
||||
When I click selected on the sort
|
||||
When I click options.1.button on the sort
|
||||
Then I see name on the authMethods vertically like yaml
|
||||
---
|
||||
- "system-D"
|
||||
- "system-C"
|
||||
- "system-B"
|
||||
- "system-A"
|
||||
---
|
||||
When I click selected on the sort
|
||||
When I click options.0.button on the sort
|
||||
Then I see name on the authMethods vertically like yaml
|
||||
---
|
||||
- "system-A"
|
||||
- "system-B"
|
||||
- "system-C"
|
||||
- "system-D"
|
||||
---
|
|
@ -0,0 +1,10 @@
|
|||
import steps from '../../../steps';
|
||||
|
||||
// step definitions that are shared between features should be moved to the
|
||||
// tests/acceptance/steps/steps.js file
|
||||
|
||||
export default function(assert) {
|
||||
return steps(assert).then('I should find a file', function() {
|
||||
assert.ok(true, this.step);
|
||||
});
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
import steps from '../../../steps';
|
||||
|
||||
// step definitions that are shared between features should be moved to the
|
||||
// tests/acceptance/steps/steps.js file
|
||||
|
||||
export default function(assert) {
|
||||
return steps(assert).then('I should find a file', function() {
|
||||
assert.ok(true, this.step);
|
||||
});
|
||||
}
|
|
@ -42,6 +42,10 @@ export default function(type, value) {
|
|||
key = 'CONSUL_TOKEN_COUNT';
|
||||
obj['CONSUL_ACLS_ENABLE'] = 1;
|
||||
break;
|
||||
case 'authMethod':
|
||||
key = 'CONSUL_AUTH_METHOD_COUNT';
|
||||
obj['CONSUL_ACLS_ENABLE'] = 1;
|
||||
break;
|
||||
case 'nspace':
|
||||
key = 'CONSUL_NSPACE_COUNT';
|
||||
break;
|
||||
|
|
|
@ -35,6 +35,9 @@ export default function(type) {
|
|||
case 'token':
|
||||
requests = ['/v1/acl/tokens', '/v1/acl/token/'];
|
||||
break;
|
||||
case 'authMethod':
|
||||
requests = ['/v1/acl/auth-methods', '/v1/acl/auth-method/'];
|
||||
break;
|
||||
case 'nspace':
|
||||
requests = ['/v1/namespaces', '/v1/namespace/'];
|
||||
break;
|
||||
|
|
|
@ -0,0 +1,50 @@
|
|||
import { module, test } from 'qunit';
|
||||
import { setupTest } from 'ember-qunit';
|
||||
import getNspaceRunner from 'consul-ui/tests/helpers/get-nspace-runner';
|
||||
|
||||
const nspaceRunner = getNspaceRunner('auth-method');
|
||||
module('Integration | Adapter | auth-method', function(hooks) {
|
||||
setupTest(hooks);
|
||||
const dc = 'dc-1';
|
||||
const id = 'slug';
|
||||
test('requestForQueryRecord returns the correct url/method', function(assert) {
|
||||
const adapter = this.owner.lookup('adapter:auth-method');
|
||||
const client = this.owner.lookup('service:client/http');
|
||||
const expected = `GET /v1/acl/auth-method/${id}?dc=${dc}`;
|
||||
const actual = adapter.requestForQueryRecord(client.requestParams.bind(client), {
|
||||
dc: dc,
|
||||
id: id,
|
||||
});
|
||||
assert.equal(`${actual.method} ${actual.url}`, expected);
|
||||
});
|
||||
test("requestForQueryRecord throws if you don't specify an id", function(assert) {
|
||||
const adapter = this.owner.lookup('adapter:auth-method');
|
||||
const client = this.owner.lookup('service:client/http');
|
||||
assert.throws(function() {
|
||||
adapter.requestForQueryRecord(client.url, {
|
||||
dc: dc,
|
||||
});
|
||||
});
|
||||
});
|
||||
test('requestForQueryRecord returns the correct body', function(assert) {
|
||||
return nspaceRunner(
|
||||
(adapter, serializer, client) => {
|
||||
return adapter.requestForQueryRecord(client.body, {
|
||||
id: id,
|
||||
dc: dc,
|
||||
ns: 'team-1',
|
||||
index: 1,
|
||||
});
|
||||
},
|
||||
{
|
||||
index: 1,
|
||||
ns: 'team-1',
|
||||
},
|
||||
{
|
||||
index: 1,
|
||||
},
|
||||
this,
|
||||
assert
|
||||
);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,75 @@
|
|||
import { module, test, skip } from 'qunit';
|
||||
import { setupTest } from 'ember-qunit';
|
||||
import { get } from 'consul-ui/tests/helpers/api';
|
||||
import {
|
||||
HEADERS_SYMBOL as META,
|
||||
HEADERS_DATACENTER as DC,
|
||||
HEADERS_NAMESPACE as NSPACE,
|
||||
} from 'consul-ui/utils/http/consul';
|
||||
module('Integration | Serializer | auth-method', function(hooks) {
|
||||
setupTest(hooks);
|
||||
const dc = 'dc-1';
|
||||
const id = 'auth-method-name';
|
||||
const undefinedNspace = 'default';
|
||||
[undefinedNspace, 'team-1', undefined].forEach(nspace => {
|
||||
test(`respondForQuery returns the correct data for list endpoint when nspace is ${nspace}`, function(assert) {
|
||||
const serializer = this.owner.lookup('serializer:auth-method');
|
||||
const request = {
|
||||
url: `/v1/acl/auth-methods?dc=${dc}${typeof nspace !== 'undefined' ? `&ns=${nspace}` : ``}`,
|
||||
};
|
||||
return get(request.url).then(function(payload) {
|
||||
const expected = payload.map(item =>
|
||||
Object.assign({}, item, {
|
||||
Datacenter: dc,
|
||||
Namespace: item.Namespace || undefinedNspace,
|
||||
uid: `["${item.Namespace || undefinedNspace}","${dc}","${item.Name}"]`,
|
||||
})
|
||||
);
|
||||
const actual = serializer.respondForQuery(
|
||||
function(cb) {
|
||||
const headers = {};
|
||||
const body = payload;
|
||||
return cb(headers, body);
|
||||
},
|
||||
{
|
||||
dc: dc,
|
||||
ns: nspace,
|
||||
}
|
||||
);
|
||||
assert.deepEqual(actual, expected);
|
||||
});
|
||||
});
|
||||
skip(`respondForQueryRecord returns the correct data for item endpoint when nspace is ${nspace}`, function(assert) {
|
||||
const serializer = this.owner.lookup('serializer:auth-method');
|
||||
const request = {
|
||||
url: `/v1/acl/auth-method/${id}?dc=${dc}${
|
||||
typeof nspace !== 'undefined' ? `&ns=${nspace}` : ``
|
||||
}`,
|
||||
};
|
||||
return get(request.url).then(function(payload) {
|
||||
const expected = Object.assign({}, payload, {
|
||||
Datacenter: dc,
|
||||
[META]: {
|
||||
[DC.toLowerCase()]: dc,
|
||||
[NSPACE.toLowerCase()]: payload.Namespace || undefinedNspace,
|
||||
},
|
||||
Namespace: payload.Namespace || undefinedNspace,
|
||||
uid: `["${payload.Namespace || undefinedNspace}","${dc}","${id}"]`,
|
||||
});
|
||||
const actual = serializer.respondForQueryRecord(
|
||||
function(cb) {
|
||||
const headers = {};
|
||||
const body = payload;
|
||||
return cb(headers, body);
|
||||
},
|
||||
{
|
||||
dc: dc,
|
||||
ns: nspace,
|
||||
id: id,
|
||||
}
|
||||
);
|
||||
assert.deepEqual(actual, expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,82 @@
|
|||
import { moduleFor, test } from 'ember-qunit';
|
||||
import repo from 'consul-ui/tests/helpers/repo';
|
||||
import { skip } from 'qunit';
|
||||
|
||||
const NAME = 'auth-method';
|
||||
moduleFor(`service:repository/${NAME}`, `Integration | Service | ${NAME}`, {
|
||||
// Specify the other units that are required for this test.
|
||||
integration: true,
|
||||
});
|
||||
const dc = 'dc-1';
|
||||
const id = 'auth-method-name';
|
||||
const undefinedNspace = 'default';
|
||||
[undefinedNspace, 'team-1', undefined].forEach(nspace => {
|
||||
test(`findAllByDatacenter returns the correct data for list endpoint when nspace is ${nspace}`, function(assert) {
|
||||
return repo(
|
||||
'auth-method',
|
||||
'findAllByDatacenter',
|
||||
this.subject(),
|
||||
function retrieveStub(stub) {
|
||||
return stub(
|
||||
`/v1/acl/auth-methods?dc=${dc}${typeof nspace !== 'undefined' ? `&ns=${nspace}` : ``}`,
|
||||
{
|
||||
CONSUL_AUTH_METHOD_COUNT: '3',
|
||||
}
|
||||
);
|
||||
},
|
||||
function performTest(service) {
|
||||
return service.findAllByDatacenter(dc, nspace || undefinedNspace);
|
||||
},
|
||||
function performAssertion(actual, expected) {
|
||||
assert.deepEqual(
|
||||
actual,
|
||||
expected(function(payload) {
|
||||
return payload.map(function(item) {
|
||||
return Object.assign({}, item, {
|
||||
Datacenter: dc,
|
||||
Namespace: item.Namespace || undefinedNspace,
|
||||
uid: `["${item.Namespace || undefinedNspace}","${dc}","${item.Name}"]`,
|
||||
});
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
skip(`findBySlug returns the correct data for item endpoint when the nspace is ${nspace}`, function(assert) {
|
||||
return repo(
|
||||
'AuthMethod',
|
||||
'findBySlug',
|
||||
this.subject(),
|
||||
function retrieveStub(stub) {
|
||||
return stub(
|
||||
`/v1/acl/auth-method/${id}?dc=${dc}${
|
||||
typeof nspace !== 'undefined' ? `&ns=${nspace}` : ``
|
||||
}`
|
||||
);
|
||||
},
|
||||
function performTest(service) {
|
||||
return service.findBySlug(id, dc, nspace || undefinedNspace);
|
||||
},
|
||||
function performAssertion(actual, expected) {
|
||||
assert.deepEqual(
|
||||
actual,
|
||||
expected(function(payload) {
|
||||
const item = payload;
|
||||
return Object.assign({}, item, {
|
||||
Datacenter: dc,
|
||||
Namespace: item.Namespace || undefinedNspace,
|
||||
uid: `["${item.Namespace || undefinedNspace}","${dc}","${item.Name}"]`,
|
||||
meta: {
|
||||
cacheControl: undefined,
|
||||
cursor: undefined,
|
||||
dc: dc,
|
||||
nspace: item.Namespace || undefinedNspace,
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
|
@ -42,6 +42,7 @@ import consulUpstreamInstanceListFactory from 'consul-ui/components/consul/upstr
|
|||
import consulTokenListFactory from 'consul-ui/components/consul/token/list/pageobject';
|
||||
import consulRoleListFactory from 'consul-ui/components/consul/role/list/pageobject';
|
||||
import consulPolicyListFactory from 'consul-ui/components/consul/policy/list/pageobject';
|
||||
import consulAuthMethodListFactory from 'consul-ui/components/consul/auth-method/list/pageobject';
|
||||
import consulIntentionListFactory from 'consul-ui/components/consul/intention/list/pageobject';
|
||||
import consulNspaceListFactory from 'consul-ui/components/consul/nspace/list/pageobject';
|
||||
import consulKvListFactory from 'consul-ui/components/consul/kv/list/pageobject';
|
||||
|
@ -65,6 +66,7 @@ import roles from 'consul-ui/tests/pages/dc/acls/roles/index';
|
|||
import role from 'consul-ui/tests/pages/dc/acls/roles/edit';
|
||||
import tokens from 'consul-ui/tests/pages/dc/acls/tokens/index';
|
||||
import token from 'consul-ui/tests/pages/dc/acls/tokens/edit';
|
||||
import authMethods from 'consul-ui/tests/pages/dc/acls/auth-methods/index';
|
||||
import intentions from 'consul-ui/tests/pages/dc/intentions/index';
|
||||
import intention from 'consul-ui/tests/pages/dc/intentions/edit';
|
||||
import nspaces from 'consul-ui/tests/pages/dc/nspaces/index';
|
||||
|
@ -90,6 +92,7 @@ const emptyState = emptyStateFactory(isPresent);
|
|||
|
||||
const consulHealthCheckList = consulHealthCheckListFactory(collection, text);
|
||||
const consulUpstreamInstanceList = consulUpstreamInstanceListFactory(collection, text);
|
||||
const consulAuthMethodList = consulAuthMethodListFactory(collection, text);
|
||||
const consulIntentionList = consulIntentionListFactory(
|
||||
collection,
|
||||
clickable,
|
||||
|
@ -191,6 +194,7 @@ export default {
|
|||
token: create(
|
||||
token(visitable, submitable, deletable, cancelable, clickable, policySelector, roleSelector)
|
||||
),
|
||||
authMethods: create(authMethods(visitable, creatable, consulAuthMethodList, popoverSelect)),
|
||||
intentions: create(
|
||||
intentions(visitable, creatable, clickable, consulIntentionList, popoverSelect)
|
||||
),
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
export default function(visitable, creatable, authMethods, popoverSelect) {
|
||||
return creatable({
|
||||
visit: visitable('/:dc/acls/auth-methods'),
|
||||
authMethods: authMethods(),
|
||||
sort: popoverSelect('[data-test-sort-control]'),
|
||||
});
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
import { module, test } from 'qunit';
|
||||
import { setupTest } from 'ember-qunit';
|
||||
|
||||
module('Unit | Adapter | auth-method', function(hooks) {
|
||||
setupTest(hooks);
|
||||
|
||||
// Replace this with your real tests.
|
||||
test('it exists', function(assert) {
|
||||
let adapter = this.owner.lookup('adapter:auth-method');
|
||||
assert.ok(adapter);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,14 @@
|
|||
import { module, test } from 'qunit';
|
||||
import { setupTest } from 'ember-qunit';
|
||||
import { run } from '@ember/runloop';
|
||||
|
||||
module('Unit | Model | auth-method', function(hooks) {
|
||||
setupTest(hooks);
|
||||
|
||||
// Replace this with your real tests.
|
||||
test('it exists', function(assert) {
|
||||
let store = this.owner.lookup('service:store');
|
||||
let model = run(() => store.createRecord('auth-method', {}));
|
||||
assert.ok(model);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,23 @@
|
|||
import { module, test } from 'qunit';
|
||||
import { setupTest } from 'ember-qunit';
|
||||
|
||||
module('Unit | Serializer | auth-method', function(hooks) {
|
||||
setupTest(hooks);
|
||||
|
||||
// Replace this with your real tests.
|
||||
test('it exists', function(assert) {
|
||||
let store = this.owner.lookup('service:store');
|
||||
let serializer = store.serializerFor('auth-method');
|
||||
|
||||
assert.ok(serializer);
|
||||
});
|
||||
|
||||
test('it serializes records', function(assert) {
|
||||
let store = this.owner.lookup('service:store');
|
||||
let record = store.createRecord('auth-method', {});
|
||||
|
||||
let serializedRecord = record.serialize();
|
||||
|
||||
assert.ok(serializedRecord);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,12 @@
|
|||
import { module, test } from 'qunit';
|
||||
import { setupTest } from 'ember-qunit';
|
||||
|
||||
module('Unit | Service | auth-method', function(hooks) {
|
||||
setupTest(hooks);
|
||||
|
||||
// Replace this with your real tests.
|
||||
test('it exists', function(assert) {
|
||||
let service = this.owner.lookup('service:repository/auth-method');
|
||||
assert.ok(service);
|
||||
});
|
||||
});
|
|
@ -6,11 +6,14 @@ common:
|
|||
vault: Vault
|
||||
aws: AWS
|
||||
kubernetes: Kubernetes
|
||||
jwt: JWT
|
||||
oidc: OIDC
|
||||
ui:
|
||||
remove: Remove {item}
|
||||
filtered-by: Filtered by {item}
|
||||
name: Name
|
||||
creation: Creation
|
||||
maxttl: Max TTL
|
||||
consul:
|
||||
name: Name
|
||||
passing: Passing
|
||||
|
@ -35,6 +38,7 @@ common:
|
|||
localbindport: Local Bind Port
|
||||
destinationname: Destination Name
|
||||
sourcename: Source Name
|
||||
displayname: Display Name
|
||||
search:
|
||||
search: Search
|
||||
searchproperty: Search Across
|
||||
|
@ -52,6 +56,9 @@ common:
|
|||
age:
|
||||
asc: Oldest to Newest
|
||||
desc: Newest to Oldest
|
||||
duration:
|
||||
asc: Longest to shortest
|
||||
desc: Shortest to longest
|
||||
status:
|
||||
asc: Unhealthy to Healthy
|
||||
desc: Healthy to Unhealthy
|
||||
|
@ -127,6 +134,15 @@ components:
|
|||
options:
|
||||
global-management: Global Management
|
||||
standard: Standard
|
||||
auth-method:
|
||||
search-bar:
|
||||
kind:
|
||||
name: Type
|
||||
locality:
|
||||
name: Source
|
||||
options:
|
||||
local: Creates local tokens
|
||||
global: Creates global tokens
|
||||
kv:
|
||||
search-bar:
|
||||
kind:
|
||||
|
|
Loading…
Reference in New Issue