mirror of
https://github.com/optim-enterprises-bv/vault.git
synced 2025-11-02 11:38:02 +00:00
UI/control group db cred (#12024)
This commit is contained in:
3
changelog/12024.txt
Normal file
3
changelog/12024.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
```release-note:bug
|
||||||
|
ui: fix control group access for database credential
|
||||||
|
```
|
||||||
@@ -1,16 +1,46 @@
|
|||||||
|
import RSVP from 'rsvp';
|
||||||
import ApplicationAdapter from '../application';
|
import ApplicationAdapter from '../application';
|
||||||
|
|
||||||
export default ApplicationAdapter.extend({
|
export default ApplicationAdapter.extend({
|
||||||
namespace: 'v1',
|
namespace: 'v1',
|
||||||
|
|
||||||
fetchByQuery(store, query) {
|
_staticCreds(backend, secret) {
|
||||||
const { backend, roleType, secret } = query;
|
|
||||||
let creds = roleType === 'static' ? 'static-creds' : 'creds';
|
|
||||||
return this.ajax(
|
return this.ajax(
|
||||||
`${this.buildURL()}/${encodeURIComponent(backend)}/${creds}/${encodeURIComponent(secret)}`,
|
`${this.buildURL()}/${encodeURIComponent(backend)}/static-creds/${encodeURIComponent(secret)}`,
|
||||||
'GET'
|
'GET'
|
||||||
|
).then(resp => ({ ...resp, roleType: 'static' }));
|
||||||
|
},
|
||||||
|
|
||||||
|
_dynamicCreds(backend, secret) {
|
||||||
|
return this.ajax(
|
||||||
|
`${this.buildURL()}/${encodeURIComponent(backend)}/creds/${encodeURIComponent(secret)}`,
|
||||||
|
'GET'
|
||||||
|
).then(resp => ({ ...resp, roleType: 'dynamic' }));
|
||||||
|
},
|
||||||
|
|
||||||
|
fetchByQuery(store, query) {
|
||||||
|
const { backend, secret } = query;
|
||||||
|
return RSVP.allSettled([this._staticCreds(backend, secret), this._dynamicCreds(backend, secret)]).then(
|
||||||
|
([staticResp, dynamicResp]) => {
|
||||||
|
// If one comes back with wrapped response from control group, throw it
|
||||||
|
const accessor = staticResp.accessor || dynamicResp.accessor;
|
||||||
|
if (accessor) {
|
||||||
|
throw accessor;
|
||||||
|
}
|
||||||
|
// if neither has payload, throw reason with highest httpStatus
|
||||||
|
if (!staticResp.value && !dynamicResp.value) {
|
||||||
|
let reason = dynamicResp.reason;
|
||||||
|
if (reason?.httpStatus < staticResp.reason?.httpStatus) {
|
||||||
|
reason = staticResp.reason;
|
||||||
|
}
|
||||||
|
throw reason;
|
||||||
|
}
|
||||||
|
// Otherwise, return whichever one has a value
|
||||||
|
return staticResp.value || dynamicResp.value;
|
||||||
|
}
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
queryRecord(store, type, query) {
|
queryRecord(store, type, query) {
|
||||||
return this.fetchByQuery(store, query);
|
return this.fetchByQuery(store, query);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { isEmpty } from '@ember/utils';
|
|||||||
import { get } from '@ember/object';
|
import { get } from '@ember/object';
|
||||||
import ApplicationAdapter from './application';
|
import ApplicationAdapter from './application';
|
||||||
import { encodePath } from 'vault/utils/path-encoding-helpers';
|
import { encodePath } from 'vault/utils/path-encoding-helpers';
|
||||||
import ControlGroupError from 'vault/lib/control-group-error';
|
|
||||||
|
|
||||||
export default ApplicationAdapter.extend({
|
export default ApplicationAdapter.extend({
|
||||||
namespace: 'v1',
|
namespace: 'v1',
|
||||||
|
|||||||
@@ -8,82 +8,19 @@
|
|||||||
* <GenerateCredentialsDatabase @backendPath="database" @backendType="database" @roleName="my-role"/>
|
* <GenerateCredentialsDatabase @backendPath="database" @backendType="database" @roleName="my-role"/>
|
||||||
* ```
|
* ```
|
||||||
* @param {string} backendPath - the secret backend name. This is used in the breadcrumb.
|
* @param {string} backendPath - the secret backend name. This is used in the breadcrumb.
|
||||||
* @param {object} backendType - the secret type. Expected to be database.
|
* @param {string} roleType - either 'static', 'dynamic', or falsey.
|
||||||
* @param {string} roleName - the id of the credential returning.
|
* @param {string} roleName - the id of the credential returning.
|
||||||
|
* @param {object} model - database/credential model passed in. If no data, should have errorTitle, errorMessage, and errorHttpStatus
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { inject as service } from '@ember/service';
|
|
||||||
import Component from '@glimmer/component';
|
import Component from '@glimmer/component';
|
||||||
import { task } from 'ember-concurrency';
|
|
||||||
import { action } from '@ember/object';
|
import { action } from '@ember/object';
|
||||||
import { tracked } from '@glimmer/tracking';
|
|
||||||
|
|
||||||
export default class GenerateCredentialsDatabase extends Component {
|
export default class GenerateCredentialsDatabase extends Component {
|
||||||
@service store;
|
get errorTitle() {
|
||||||
// set on the component
|
return this.args.model.errorTitle || 'Something went wrong';
|
||||||
backendType = null;
|
|
||||||
backendPath = null;
|
|
||||||
roleName = null;
|
|
||||||
@tracked roleType = '';
|
|
||||||
@tracked model = null;
|
|
||||||
@tracked errorMessage = '';
|
|
||||||
@tracked errorHttpStatus = '';
|
|
||||||
@tracked errorTitle = 'Something went wrong';
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
super(...arguments);
|
|
||||||
this.fetchCredentials.perform();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@task(function*() {
|
|
||||||
let { roleName, backendPath } = this.args;
|
|
||||||
try {
|
|
||||||
let newModel = yield this.store.queryRecord('database/credential', {
|
|
||||||
backend: backendPath,
|
|
||||||
secret: roleName,
|
|
||||||
roleType: 'static',
|
|
||||||
});
|
|
||||||
this.model = newModel;
|
|
||||||
this.roleType = 'static';
|
|
||||||
return;
|
|
||||||
} catch (error) {
|
|
||||||
this.errorHttpStatus = error.httpStatus; // set default http
|
|
||||||
this.errorMessage = `We ran into a problem and could not continue: ${error.errors[0]}`;
|
|
||||||
if (error.httpStatus === 403) {
|
|
||||||
// 403 is forbidden
|
|
||||||
this.errorTitle = 'You are not authorized';
|
|
||||||
this.errorMessage =
|
|
||||||
"Role wasn't found or you do not have permissions. Ask your administrator if you think you should have access.";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
let newModel = yield this.store.queryRecord('database/credential', {
|
|
||||||
backend: backendPath,
|
|
||||||
secret: roleName,
|
|
||||||
roleType: 'dynamic',
|
|
||||||
});
|
|
||||||
this.model = newModel;
|
|
||||||
this.roleType = 'dynamic';
|
|
||||||
return;
|
|
||||||
} catch (error) {
|
|
||||||
if (error.httpStatus === 403) {
|
|
||||||
// 403 is forbidden
|
|
||||||
this.errorHttpStatus = error.httpStatus; // override default httpStatus which could be 400 which always happens on either dynamic or static depending on which kind of role you're querying
|
|
||||||
this.errorTitle = 'You are not authorized';
|
|
||||||
this.errorMessage =
|
|
||||||
"Role wasn't found or you do not have permissions. Ask your administrator if you think you should have access.";
|
|
||||||
}
|
|
||||||
if (error.httpStatus == 500) {
|
|
||||||
// internal server error happens when empty creation statement on dynamic role creation only
|
|
||||||
this.errorHttpStatus = error.httpStatus;
|
|
||||||
this.errorTitle = 'Internal Error';
|
|
||||||
this.errorMessage = error.errors[0];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.roleType = 'noRoleFound';
|
|
||||||
})
|
|
||||||
fetchCredentials;
|
|
||||||
|
|
||||||
@action redirectPreviousPage() {
|
@action redirectPreviousPage() {
|
||||||
window.history.back();
|
window.history.back();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,10 @@ export default class SecretListHeaderTab extends Component {
|
|||||||
let array = [];
|
let array = [];
|
||||||
// we only want to look at the canList, canCreate and canUpdate on the capabilities record
|
// we only want to look at the canList, canCreate and canUpdate on the capabilities record
|
||||||
capabilitiesArray.forEach(item => {
|
capabilitiesArray.forEach(item => {
|
||||||
array.push(object[item]);
|
// object is sometimes null
|
||||||
|
if (object) {
|
||||||
|
array.push(object[item]);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
return array;
|
return array;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,4 +8,5 @@ export default Model.extend({
|
|||||||
lastVaultRotation: attr('string'),
|
lastVaultRotation: attr('string'),
|
||||||
rotationPeriod: attr('number'),
|
rotationPeriod: attr('number'),
|
||||||
ttl: attr('number'),
|
ttl: attr('number'),
|
||||||
|
roleType: attr('string'),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { resolve } from 'rsvp';
|
import { resolve } from 'rsvp';
|
||||||
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 ControlGroupError from 'vault/lib/control-group-error';
|
||||||
|
|
||||||
const SUPPORTED_DYNAMIC_BACKENDS = ['database', 'ssh', 'aws', 'pki'];
|
const SUPPORTED_DYNAMIC_BACKENDS = ['database', 'ssh', 'aws', 'pki'];
|
||||||
|
|
||||||
@@ -22,13 +23,42 @@ export default Route.extend({
|
|||||||
return this.pathHelp.getNewModel(modelType, backend);
|
return this.pathHelp.getNewModel(modelType, backend);
|
||||||
},
|
},
|
||||||
|
|
||||||
model(params) {
|
getDatabaseCredential(backend, secret) {
|
||||||
|
return this.store.queryRecord('database/credential', { backend, secret }).catch(error => {
|
||||||
|
if (error instanceof ControlGroupError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
// Unless it's a control group error, we want to pass back error info
|
||||||
|
// so we can render it on the GenerateCredentialsDatabase component
|
||||||
|
let status = error?.httpStatus;
|
||||||
|
let title;
|
||||||
|
let message = `We ran into a problem and could not continue: ${
|
||||||
|
error?.errors ? error.errors[0] : 'See Vault logs for details.'
|
||||||
|
}`;
|
||||||
|
if (status === 403) {
|
||||||
|
// 403 is forbidden
|
||||||
|
title = 'You are not authorized';
|
||||||
|
message =
|
||||||
|
"Role wasn't found or you do not have permissions. Ask your administrator if you think you should have access.";
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
errorHttpStatus: status,
|
||||||
|
errorTitle: title,
|
||||||
|
errorMessage: message,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async model(params) {
|
||||||
let role = params.secret;
|
let role = params.secret;
|
||||||
let backendModel = this.backendModel();
|
let backendModel = this.backendModel();
|
||||||
let backendPath = backendModel.get('id');
|
let backendPath = backendModel.get('id');
|
||||||
let backendType = backendModel.get('type');
|
let backendType = backendModel.get('type');
|
||||||
let roleType = params.roleType;
|
let roleType = params.roleType;
|
||||||
|
let dbCred;
|
||||||
|
if (backendType === 'database') {
|
||||||
|
dbCred = await this.getDatabaseCredential(backendPath, role);
|
||||||
|
}
|
||||||
if (!SUPPORTED_DYNAMIC_BACKENDS.includes(backendModel.get('type'))) {
|
if (!SUPPORTED_DYNAMIC_BACKENDS.includes(backendModel.get('type'))) {
|
||||||
return this.transitionTo('vault.cluster.secrets.backend.list-root', backendPath);
|
return this.transitionTo('vault.cluster.secrets.backend.list-root', backendPath);
|
||||||
}
|
}
|
||||||
@@ -37,6 +67,7 @@ export default Route.extend({
|
|||||||
backendType,
|
backendType,
|
||||||
roleName: role,
|
roleName: role,
|
||||||
roleType,
|
roleType,
|
||||||
|
dbCred,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import RESTSerializer from '@ember-data/serializer/rest';
|
import RESTSerializer from '@ember-data/serializer/rest';
|
||||||
|
|
||||||
export default RESTSerializer.extend({
|
export default RESTSerializer.extend({
|
||||||
primaryKey: 'request_id',
|
primaryKey: 'username',
|
||||||
|
|
||||||
normalizePayload(payload) {
|
normalizePayload(payload) {
|
||||||
if (payload.data) {
|
if (payload.data) {
|
||||||
const credentials = {
|
return {
|
||||||
request_id: payload.request_id,
|
|
||||||
username: payload.data.username,
|
username: payload.data.username,
|
||||||
password: payload.data.password,
|
password: payload.data.password,
|
||||||
leaseId: payload.lease_id,
|
leaseId: payload.lease_id,
|
||||||
@@ -14,8 +13,9 @@ export default RESTSerializer.extend({
|
|||||||
lastVaultRotation: payload.data.last_vault_rotation,
|
lastVaultRotation: payload.data.last_vault_rotation,
|
||||||
rotationPeriod: payload.data.rotation_period,
|
rotationPeriod: payload.data.rotation_period,
|
||||||
ttl: payload.data.ttl,
|
ttl: payload.data.ttl,
|
||||||
|
// roleType is added on adapter
|
||||||
|
roleType: payload.roleType,
|
||||||
};
|
};
|
||||||
return credentials;
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
{{on 'click' (fn this.generateCreds @model.id)}}
|
{{on 'click' (fn this.generateCreds @model.id)}}
|
||||||
data-test-database-role-generate-creds
|
data-test-database-role-generate-creds
|
||||||
>
|
>
|
||||||
Generate credentials
|
{{if (eq @model.type "static") "Get credentials" "Generate credentials"}}
|
||||||
</button>
|
</button>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
{{#if @model.canEditRole}}
|
{{#if @model.canEditRole}}
|
||||||
|
|||||||
@@ -18,15 +18,15 @@
|
|||||||
</p.levelLeft>
|
</p.levelLeft>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<div class={{unless (eq this.roleType 'noRoleFound') "box is-fullwidth is-sideless is-marginless"}}>
|
<div class={{if @roleType "box is-fullwidth is-sideless is-marginless"}}>
|
||||||
{{!-- ROLE TYPE NOT FOUND, returned when query on the creds and static creds both returned error --}}
|
{{!-- If no role type, that means both static and dynamic requests returned an error --}}
|
||||||
{{#if (eq this.roleType 'noRoleFound') }}
|
{{#unless @roleType }}
|
||||||
<EmptyState
|
<EmptyState
|
||||||
@title={{this.errorTitle}}
|
@title={{errorTitle}}
|
||||||
@subTitle="Error {{this.errorHttpStatus}}"
|
@subTitle="Error {{@model.errorHttpStatus}}"
|
||||||
@icon="alert-circle-outline"
|
@icon="alert-circle-outline"
|
||||||
@bottomBorder={{true}}
|
@bottomBorder={{true}}
|
||||||
@message={{this.errorMessage}}
|
@message={{@model.errorMessage}}
|
||||||
>
|
>
|
||||||
<nav class="breadcrumb">
|
<nav class="breadcrumb">
|
||||||
<ul class="is-grouped-split">
|
<ul class="is-grouped-split">
|
||||||
@@ -41,8 +41,8 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
</EmptyState>
|
</EmptyState>
|
||||||
{{/if}}
|
{{/unless}}
|
||||||
{{#unless (or model.errorMessage (eq this.roleType 'noRoleFound'))}}
|
{{#unless (or @model.errorMessage (not @roleType))}}
|
||||||
<AlertBanner
|
<AlertBanner
|
||||||
@type="warning"
|
@type="warning"
|
||||||
@message="You will not be able to access these credentials later, so please copy them now."
|
@message="You will not be able to access these credentials later, so please copy them now."
|
||||||
@@ -50,44 +50,44 @@
|
|||||||
/>
|
/>
|
||||||
{{/unless}}
|
{{/unless}}
|
||||||
{{!-- DYNAMIC ROLE --}}
|
{{!-- DYNAMIC ROLE --}}
|
||||||
{{#if (and (eq this.roleType 'dynamic') model.username)}}
|
{{#if (and (eq @roleType 'dynamic') @model.username)}}
|
||||||
<InfoTableRow @label="Username" @value={{model.username}}>
|
<InfoTableRow @label="Username" @value={{@model.username}}>
|
||||||
<MaskedInput
|
<MaskedInput
|
||||||
@value={{model.username}}
|
@value={{@model.username}}
|
||||||
@name="Username"
|
@name="Username"
|
||||||
@displayOnly={{true}}
|
@displayOnly={{true}}
|
||||||
@allowCopy={{true}}
|
@allowCopy={{true}}
|
||||||
/>
|
/>
|
||||||
</InfoTableRow>
|
</InfoTableRow>
|
||||||
<InfoTableRow @label="Password" @value={{model.password}}>
|
<InfoTableRow @label="Password" @value={{@model.password}}>
|
||||||
<MaskedInput
|
<MaskedInput
|
||||||
@value={{model.password}}
|
@value={{@model.password}}
|
||||||
@name="Password"
|
@name="Password"
|
||||||
@displayOnly={{true}}
|
@displayOnly={{true}}
|
||||||
@allowCopy={{true}}
|
@allowCopy={{true}}
|
||||||
/>
|
/>
|
||||||
</InfoTableRow>
|
</InfoTableRow>
|
||||||
<InfoTableRow @label="Lease ID" @value={{model.leaseId}} />
|
<InfoTableRow @label="Lease ID" @value={{@model.leaseId}} />
|
||||||
<InfoTableRow @label="Lease Duration" @value={{format-duration model.leaseDuration }} />
|
<InfoTableRow @label="Lease Duration" @value={{format-duration @model.leaseDuration }} />
|
||||||
{{/if}}
|
{{/if}}
|
||||||
{{!-- STATIC ROLE --}}
|
{{!-- STATIC ROLE --}}
|
||||||
{{#if (and (eq this.roleType 'static') model.username)}}
|
{{#if (and (eq @roleType 'static') @model.username)}}
|
||||||
<InfoTableRow
|
<InfoTableRow
|
||||||
@label="Last Vault rotation"
|
@label="Last Vault rotation"
|
||||||
@value={{date-format model.lastVaultRotation 'MMMM d yyyy, h:mm:ss a'}}
|
@value={{date-format @model.lastVaultRotation 'MMMM d yyyy, h:mm:ss a'}}
|
||||||
@tooltipText={{model.lastVaultRotation}}
|
@tooltipText={{@model.lastVaultRotation}}
|
||||||
/>
|
/>
|
||||||
<InfoTableRow @label="Password" @value={{model.password}}>
|
<InfoTableRow @label="Password" @value={{@model.password}}>
|
||||||
<MaskedInput
|
<MaskedInput
|
||||||
@value={{model.password}}
|
@value={{@model.password}}
|
||||||
@name="Password"
|
@name="Password"
|
||||||
@displayOnly={{true}}
|
@displayOnly={{true}}
|
||||||
@allowCopy={{true}}
|
@allowCopy={{true}}
|
||||||
/>
|
/>
|
||||||
</InfoTableRow>
|
</InfoTableRow>
|
||||||
<InfoTableRow @label="Username" @value={{model.username}} />
|
<InfoTableRow @label="Username" @value={{@model.username}} />
|
||||||
<InfoTableRow @label="Rotation Period" @value={{format-duration model.rotationPeriod}} />
|
<InfoTableRow @label="Rotation Period" @value={{format-duration @model.rotationPeriod}} />
|
||||||
<InfoTableRow @label="Time Remaining" @value={{format-duration model.ttl}} />
|
<InfoTableRow @label="Time Remaining" @value={{format-duration @model.ttl}} />
|
||||||
{{/if}}
|
{{/if}}
|
||||||
</div>
|
</div>
|
||||||
<div class="has-top-bottom-margin">
|
<div class="has-top-bottom-margin">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<div class="selectable-card is-rounded no-flex">
|
<form class="selectable-card is-rounded no-flex">
|
||||||
<div class="is-flex-between is-fullwidth card-details" >
|
<div class="is-flex-between is-fullwidth card-details" >
|
||||||
<h3 class="title is-5">{{@title}}</h3>
|
<h3 class="title is-5">{{@title}}</h3>
|
||||||
</div>
|
</div>
|
||||||
@@ -15,13 +15,13 @@
|
|||||||
@inputValue={{get model valuePath}}
|
@inputValue={{get model valuePath}}
|
||||||
data-test-search-roles
|
data-test-search-roles
|
||||||
/>
|
/>
|
||||||
<button
|
<input
|
||||||
type="button"
|
type="submit"
|
||||||
|
value={{@title}}
|
||||||
class="button is-secondary"
|
class="button is-secondary"
|
||||||
disabled={{buttonDisabled}}
|
disabled={{buttonDisabled}}
|
||||||
onclick={{action "transitionToCredential"}}
|
onclick={{action "transitionToCredential"}}
|
||||||
data-test-get-credentials
|
data-test-get-credentials
|
||||||
>
|
/>
|
||||||
{{@title}}
|
|
||||||
</button>
|
</form>
|
||||||
</div>
|
|
||||||
|
|||||||
@@ -53,7 +53,7 @@
|
|||||||
{{#if @item.canGenerateCredentials}}
|
{{#if @item.canGenerateCredentials}}
|
||||||
<li class="action">
|
<li class="action">
|
||||||
<LinkTo @route="vault.cluster.secrets.backend.credentials" @model={{@item.id}} @query={{hash roleType=this.keyTypeValue}}>
|
<LinkTo @route="vault.cluster.secrets.backend.credentials" @model={{@item.id}} @query={{hash roleType=this.keyTypeValue}}>
|
||||||
Generate credentials
|
{{if (eq @item.type "static") "Get credentials" "Generate credentials"}}
|
||||||
</LinkTo>
|
</LinkTo>
|
||||||
</li>
|
</li>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
{{#if (eq model.backendType 'database')}}
|
{{#if (eq model.backendType 'database')}}
|
||||||
<GenerateCredentialsDatabase
|
<GenerateCredentialsDatabase
|
||||||
@backendPath={{model.backendPath}}
|
@backendPath={{model.backendPath}}
|
||||||
@backendType={{model.backendType}}
|
|
||||||
@roleName={{model.roleName}}
|
@roleName={{model.roleName}}
|
||||||
@roleType={{model.roleType}}
|
@roleType={{model.dbCred.roleType}}
|
||||||
|
@model={{model.dbCred}}
|
||||||
/>
|
/>
|
||||||
{{else}}
|
{{else}}
|
||||||
{{!-- TODO smells a little to have action off of query param requiring a conditional --}}
|
{{!-- TODO smells a little to have action off of query param requiring a conditional --}}
|
||||||
|
|||||||
Reference in New Issue
Block a user