Merge pull request #723 from dod-ccpo/add-portfolio-user

Add portfolio user
This commit is contained in:
dandds 2019-03-29 10:26:21 -04:00 committed by GitHub
commit a1faa058bd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 415 additions and 68 deletions

View File

@ -84,7 +84,7 @@ def make_flask_callbacks(app):
def _set_globals(): def _set_globals():
g.current_user = None g.current_user = None
g.dev = os.getenv("FLASK_ENV", "dev") == "dev" g.dev = os.getenv("FLASK_ENV", "dev") == "dev"
g.matchesPath = lambda href: re.match("^" + href, request.path) g.matchesPath = lambda href: re.search(href, request.full_path)
g.modal = request.args.get("modal", None) g.modal = request.args.get("modal", None)
g.Authorization = Authorization g.Authorization = Authorization
g.Permissions = Permissions g.Permissions = Permissions

View File

@ -1,10 +1,10 @@
from wtforms.fields import StringField, FormField, FieldList from wtforms.fields import StringField, FormField, FieldList
from wtforms.fields.html5 import EmailField from wtforms.fields.html5 import EmailField, TelField
from wtforms.validators import Required, Email, Length from wtforms.validators import Required, Email, Length, Optional
from atst.domain.permission_sets import PermissionSets from atst.domain.permission_sets import PermissionSets
from .forms import BaseForm from .forms import BaseForm
from atst.forms.validators import IsNumber from atst.forms.validators import IsNumber, PhoneNumber
from atst.forms.fields import SelectField from atst.forms.fields import SelectField
from atst.utils.localization import translate from atst.utils.localization import translate
@ -71,6 +71,10 @@ class NewForm(PermissionsForm):
email = EmailField( email = EmailField(
translate("forms.new_member.email_label"), validators=[Required(), Email()] translate("forms.new_member.email_label"), validators=[Required(), Email()]
) )
phone_number = TelField(
translate("forms.new_member.phone_number_label"),
validators=[Optional(), PhoneNumber()],
)
dod_id = StringField( dod_id = StringField(
translate("forms.new_member.dod_id_label"), translate("forms.new_member.dod_id_label"),
validators=[Required(), Length(min=10), IsNumber()], validators=[Required(), Length(min=10), IsNumber()],

View File

@ -8,7 +8,7 @@ from atst.domain.portfolios import Portfolios
from atst.domain.audit_log import AuditLog from atst.domain.audit_log import AuditLog
from atst.domain.common import Paginator from atst.domain.common import Paginator
from atst.forms.portfolio import PortfolioForm from atst.forms.portfolio import PortfolioForm
from atst.forms.portfolio_member import MembersPermissionsForm import atst.forms.portfolio_member as member_forms
from atst.models.permissions import Permissions from atst.models.permissions import Permissions
from atst.domain.permission_sets import PermissionSets from atst.domain.permission_sets import PermissionSets
from atst.domain.authz.decorator import user_can_access_decorator as user_can from atst.domain.authz.decorator import user_can_access_decorator as user_can
@ -63,7 +63,7 @@ def render_admin_page(portfolio, form=None):
members_data = [serialize_member_form_data(member) for member in portfolio.members] members_data = [serialize_member_form_data(member) for member in portfolio.members]
portfolio_form = PortfolioForm(data={"name": portfolio.name}) portfolio_form = PortfolioForm(data={"name": portfolio.name})
member_perms_form = MembersPermissionsForm( member_perms_form = member_forms.MembersPermissionsForm(
data={"members_permissions": members_data} data={"members_permissions": members_data}
) )
return render_template( return render_template(
@ -71,6 +71,7 @@ def render_admin_page(portfolio, form=None):
form=form, form=form,
portfolio_form=portfolio_form, portfolio_form=portfolio_form,
member_perms_form=member_perms_form, member_perms_form=member_perms_form,
member_form=member_forms.NewForm(),
portfolio=portfolio, portfolio=portfolio,
audit_events=audit_events, audit_events=audit_events,
user=g.current_user, user=g.current_user,

View File

@ -92,7 +92,12 @@ def create_member(portfolio_id):
flash("new_portfolio_member", new_member=member, portfolio=portfolio) flash("new_portfolio_member", new_member=member, portfolio=portfolio)
return redirect( return redirect(
url_for("portfolios.portfolio_members", portfolio_id=portfolio.id) url_for(
"portfolios.portfolio_admin",
portfolio_id=portfolio.id,
fragment="portfolio-members",
_anchor="portfolio-members",
)
) )
except AlreadyExistsError: except AlreadyExistsError:
return render_template( return render_template(

View File

@ -22,10 +22,9 @@ MESSAGES = {
"category": "success", "category": "success",
}, },
"new_portfolio_member": { "new_portfolio_member": {
"title_template": "Member added successfully", "title_template": "Success!",
"message_template": """ "message_template": """
<p>{{ new_member.user_name }} was successfully invited via email to this portfolio. They do not yet have access to any environments.</p> <p>You have successfully invited {{ new_member.user_name }} to the portfolio admin.</p>
<p><a href="{{ url_for('portfolios.update_member', portfolio_id=portfolio.id, member_id=new_member.user_id) }}">Add environment access.</a></p>
""", """,
"category": "success", "category": "success",
}, },

View File

@ -0,0 +1,82 @@
import FormMixin from '../../mixins/form'
import textinput from '../text_input'
import optionsinput from '../options_input'
import Selector from '../selector'
import Modal from '../../mixins/modal'
import toggler from '../toggler'
export default {
name: 'multi-step-modal-form',
mixins: [FormMixin, Modal],
components: {
toggler,
Modal,
Selector,
textinput,
optionsinput,
},
props: {
steps: Number,
},
data: function() {
return {
step: 0,
fields: {},
invalid: true,
}
},
created: function() {
this.$root.$on('field-mount', this.handleFieldMount)
},
mounted: function() {
this.$root.$on('field-change', this.handleValidChange)
this.$on('modalOpen', this.handleModalOpen)
},
methods: {
next: function() {
if (this._checkIsValid()) {
this.step += 1
}
},
goToStep: function(step) {
if (this._checkIsValid()) {
this.step = step
}
},
handleValidChange: function(event) {
const { name, valid } = event
this.fields[name] = valid
this._checkIsValid()
},
_checkIsValid: function() {
const valid = !Object.values(this.fields).some(field => field === false)
this.invalid = !valid
return valid
},
handleFieldMount: function(event) {
const { name, optional } = event
this.fields[name] = optional
},
handleModalOpen: function(_bool) {
this.step = 0
},
_onLastPage: function() {
return this.step === this.steps - 1
},
handleSubmit: function(e) {
if (this.invalid || !this._onLastPage()) {
e.preventDefault()
this.next()
}
},
},
computed: {},
}

View File

@ -25,6 +25,7 @@ export default {
}, },
paragraph: String, paragraph: String,
noMaxWidth: String, noMaxWidth: String,
optional: Boolean,
}, },
data: function() { data: function() {
@ -64,6 +65,13 @@ export default {
} }
}, },
created: function() {
this.$root.$emit('field-mount', {
name: this.name,
optional: this.optional,
})
},
methods: { methods: {
// When user types a character // When user types a character
onInput: function(e) { onInput: function(e) {
@ -82,7 +90,9 @@ export default {
}, },
onBlur: function(e) { onBlur: function(e) {
if (!(this.optional && e.target.value === '')) {
this._checkIfValid({ value: e.target.value.trim(), invalidate: true }) this._checkIfValid({ value: e.target.value.trim(), invalidate: true })
}
this.value = e.target.value.trim() this.value = e.target.value.trim()
if (this.validation === 'dollars') { if (this.validation === 'dollars') {
@ -97,6 +107,8 @@ export default {
if (!this.modified && this.initialErrors && this.initialErrors.length) { if (!this.modified && this.initialErrors && this.initialErrors.length) {
valid = false valid = false
} else if (this.optional && value === '') {
valid = true
} }
if (this.modified) { if (this.modified) {

View File

@ -18,6 +18,7 @@ import toggler from './components/toggler'
import NewApplication from './components/forms/new_application' import NewApplication from './components/forms/new_application'
import EditEnvironmentRole from './components/forms/edit_environment_role' import EditEnvironmentRole from './components/forms/edit_environment_role'
import EditApplicationRoles from './components/forms/edit_application_roles' import EditApplicationRoles from './components/forms/edit_application_roles'
import MultiStepModalForm from './components/forms/multi_step_modal_form'
import funding from './components/forms/funding' import funding from './components/forms/funding'
import uploadinput from './components/upload_input' import uploadinput from './components/upload_input'
import Modal from './mixins/modal' import Modal from './mixins/modal'
@ -59,6 +60,7 @@ const app = new Vue({
LocalDatetime, LocalDatetime,
EditEnvironmentRole, EditEnvironmentRole,
EditApplicationRoles, EditApplicationRoles,
MultiStepModalForm,
ConfirmationPopover, ConfirmationPopover,
funding, funding,
uploadinput, uploadinput,

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 500"><path d="M434.539,98.499l-38.828-38.828c-5.324-5.328-11.799-7.993-19.41-7.993c-7.618,0-14.093,2.665-19.417,7.993L169.59,247.248 l-83.939-84.225c-5.33-5.33-11.801-7.992-19.412-7.992c-7.616,0-14.087,2.662-19.417,7.992L7.994,201.852 C2.664,207.181,0,213.654,0,221.269c0,7.609,2.664,14.088,7.994,19.416l103.351,103.349l38.831,38.828 c5.327,5.332,11.8,7.994,19.414,7.994c7.611,0,14.084-2.669,19.414-7.994l38.83-38.828L434.539,137.33 c5.325-5.33,7.994-11.802,7.994-19.417C442.537,110.302,439.864,103.829,434.539,98.499z" fill="#fff" fill-rule="nonzero"/></svg>

After

Width:  |  Height:  |  Size: 617 B

View File

@ -139,4 +139,81 @@ body {
} }
} }
} }
&.wide {
.modal__dialog {
max-width: 90rem;
}
.modal__body {
padding-left: 4rem;
padding-right: 4rem;
}
}
.modal__form {
.modal__form--header {
margin-bottom: 4rem;
h1 {
margin-bottom: 0;
}
.icon-link {
padding-top: 0.5rem;
padding-left: 0;
margin-left: 0;
}
}
.progress-menu ul {
width: 40%;
margin-left: 30%;
font-size: 2rem;
.progress-menu__item::before {
width: 2.8rem;
height: 2.8rem;
margin-left: -1.25rem;
}
.progress-menu__item--complete::before {
content: url('#{$asset-path}/icons/checkmark-alt.svg');
padding-top: 0.4rem;
}
}
.form-row {
margin-top: 0;
.form-col {
.usa-input {
margin-bottom: 1.5rem;
}
}
}
.icon-link--default {
font-size: 1.7rem;
}
.usa-button {
margin-left: 2rem;
&[type='button']:disabled {
background-color: $color-gray-lighter;
opacity: inherit;
}
}
.modal__form--padded {
padding-left: 5%;
padding-right: 5%;
.usa-input .usa-input__choices select {
max-width: 100%;
}
}
}
} }

View File

@ -197,6 +197,7 @@
} }
table { table {
thead { thead {
th:first-child { th:first-child {
padding-left: 3 * $gap; padding-left: 3 * $gap;
@ -294,6 +295,25 @@
float: right; float: right;
padding: 3 * $gap; padding: 3 * $gap;
} }
a.modal-link.icon-link {
float: right;
.icon {
width: 1.7rem;
height: 1.7rem;
svg {
width: 1.7rem;
height: 1.7rem;
}
}
}
.alert {
margin: 4rem;
}
} }
.application-content { .application-content {

View File

@ -1,8 +1,8 @@
{% from "components/icon.html" import Icon %} {% from "components/icon.html" import Icon %}
{% macro Modal(name, dismissable=False) -%} {% macro Modal(name, dismissable=False, classes="") -%}
<div v-show="activeModal === '{{name}}'" v-cloak> <div v-show="activeModal === '{{name}}'" v-cloak>
<div id='modal--{{name}}' class='modal {% if dismissable %}modal--dismissable{% endif%}'> <div id='modal--{{name}}' class='modal {% if dismissable %}modal--dismissable{% endif%} {{ classes }}'>
<div class='modal__container'> <div class='modal__container'>
<div class='modal__dialog' role='dialog' aria-modal='true'> <div class='modal__dialog' role='dialog' aria-modal='true'>
<div class='modal__body'> <div class='modal__body'>

View File

@ -0,0 +1,51 @@
{% from "components/modal.html" import Modal %}
{% from "components/icon.html" import Icon %}
{% set numbers = ['one', 'two', 'three', 'four', 'five'] %}
{% macro FormSteps(step_count, current_step) -%}
{% set count = numbers[step_count - 1] %}
<div class="progress-menu progress-menu--{{ count }}">
<ul>
{% for step in range(step_count) %}
<li class="progress-menu__item
{% if loop.index < current_step %}
progress-menu__item--complete
{% elif loop.index == current_step %}
progress-menu__item--active
{% else %}
progress-menu__item--incomplete
{% endif %}">
<a v-on:click="goToStep({{ step }})">
Step {{ loop.index }}
</a>
</li>
{% endfor %}
</ul>
</div>
{% endmacro %}
{% macro MultiStepModalForm(name, form, form_action, steps, button_text="", dismissable=False) -%}
{% set step_count = steps|length %}
<multi-step-modal-form inline-template :steps={{ step_count }}>
<div>
<a class='icon-link modal-link' v-on:click="openModal('{{ name }}')">
{{ button_text }}
{{ Icon('plus-circle-solid') }}
</a>
{% call Modal(name=name, dismissable=dismissable, classes="wide") %}
<form id="{{ name }}" action="{{ form_action }}" method="POST" v-on:submit="handleSubmit">
{{ form.csrf_token }}
<div v-if="activeModal === '{{ name }}'">
{% for step in steps %}
<div class="modal__form" v-show="step === {{ loop.index0 }}">
{{ FormSteps(step_count, loop.index) }}
{{ step }}
</div>
{% endfor %}
</div>
</form>
{% endcall %}
</div>
</multi-step-modal-form>
{% endmacro %}

View File

@ -7,7 +7,8 @@
inline-template inline-template
{% if field.errors %}v-bind:initial-errors='{{ field.errors | list }}'{% endif %} {% if field.errors %}v-bind:initial-errors='{{ field.errors | list }}'{% endif %}
{% if field.data and field.data != "None" %}v-bind:initial-value="'{{ field.data }}'"{% endif %} {% if field.data and field.data != "None" %}v-bind:initial-value="'{{ field.data }}'"{% endif %}
key='{{ field.name }}'> key='{{ field.name }}'
>
<div <div
v-bind:class="['usa-input', { 'usa-input--error': showError, 'usa-input--success': showValid }]"> v-bind:class="['usa-input', { 'usa-input--error': showError, 'usa-input--success': showValid }]">

View File

@ -13,7 +13,8 @@
disabled=False, disabled=False,
initial_value='', initial_value='',
classes='', classes='',
noMaxWidth=False) -%} noMaxWidth=False,
optional=False) -%}
<textinput <textinput
v-cloak v-cloak
@ -23,6 +24,7 @@
{% if noMaxWidth %}no-max-width='true'{% endif %} {% if noMaxWidth %}no-max-width='true'{% endif %}
{% if initial_value or field.data is not none %}initial-value='{{ initial_value or field.data }}'{% endif %} {% if initial_value or field.data is not none %}initial-value='{{ initial_value or field.data }}'{% endif %}
{% if field.errors %}v-bind:initial-errors='{{ field.errors | list }}'{% endif %} {% if field.errors %}v-bind:initial-errors='{{ field.errors | list }}'{% endif %}
v-bind:optional={{ optional|lower }}
key='{{ field.name }}' key='{{ field.name }}'
inline-template> inline-template>

View File

@ -0,0 +1,84 @@
{% from "components/icon.html" import Icon %}
{% from "components/text_input.html" import TextInput %}
{% from "components/multi_step_modal_form.html" import MultiStepModalForm %}
{% macro SimpleOptionsInput(field) %}
<div class="usa-input">
<fieldset data-ally-disabled="true" class="usa-input__choices">
<legend>
<div class="usa-input__title-inline">
{{ field.label | striptags}}
</div>
</legend>
{{ field() }}
</fieldset>
</div>
{% endmacro %}
{% set step_one %}
<div class="modal__form--header">
<h1>Invite New Portfolio Member</h1>
</div>
<div class='form-row'>
<div class='form-col form-col--half'>
{{ TextInput(member_form.first_name, validation='requiredField') }}
</div>
<div class='form-col form-col--half'>
{{ TextInput(member_form.last_name, validation='requiredField') }}
</div>
</div>
<div class='form-row'>
<div class='form-col form-col--half'>
{{ TextInput(member_form.email, validation='email') }}
</div>
<div class='form-col form-col--half'>
{{ TextInput(member_form.phone_number, validation='usPhone', optional=True) }}
</div>
</div>
<div class='form-row'>
<div class='form-col form-col--half'>
{{ TextInput(member_form.dod_id, validation='dodId') }}
</div>
<div class='form-col form-col--half'>
</div>
</div>
<div class='action-group'>
<input
type='button'
v-on:click="next()"
v-bind:disabled="invalid"
class='action-group__action usa-button'
value='Next Step'>
<a class='action-group__action icon-link icon-link--default' v-on:click="closeModal('{{ new_port_mem }}')">Cancel</a>
</div>
{% endset %}
{% set step_two %}
<div class="modal__form--padded">
<div class="modal__form--header">
<h1>Assign Member Permissions</h1>
<a class='icon-link'>
{{ Icon('info') }}
{{ "portfolios.admin.permissions_info" | translate }}
</a>
</div>
{{ SimpleOptionsInput(member_form.perms_app_mgmt) }}
{{ SimpleOptionsInput(member_form.perms_funding) }}
{{ SimpleOptionsInput(member_form.perms_reporting) }}
{{ SimpleOptionsInput(member_form.perms_portfolio_mgmt) }}
<div class='action-group'>
<input
type="submit"
class='action-group__action usa-button'
form="add-port-mem"
value='Invite Member'>
<a class='action-group__action icon-link icon-link--default' v-on:click="closeModal('{{ new_port_mem }}')">Cancel</a>
</div>
</div>
{% endset %}
{{ MultiStepModalForm(
'add-port-mem',
member_form,
url_for("portfolios.create_member", portfolio_id=portfolio.id),
[step_one, step_two],
button_text="portfolios.admin.add_new_member" | translate)
}}

View File

@ -1,10 +1,12 @@
{% from "components/icon.html" import Icon %} {% from "components/icon.html" import Icon %}
{% from "components/options_input.html" import OptionsInput %} {% from "components/options_input.html" import OptionsInput %}
<section class="member-list"> <section class="member-list" id="portfolio-members">
<div class='responsive-table-wrapper panel'> <div class='responsive-table-wrapper panel'>
<form method='POST' autocomplete="off" enctype="multipart/form-data"> {% if g.matchesPath("portfolio-members") %}
{% include "fragments/flash.html" %}
{% endif %}
<form method='POST' id="member-perms" autocomplete="off" enctype="multipart/form-data">
<div class='member-list-header'> <div class='member-list-header'>
<div class='left'> <div class='left'>
<div class='h3'>{{ "portfolios.admin.portfolio_members_title" | translate }}</div> <div class='h3'>{{ "portfolios.admin.portfolio_members_title" | translate }}</div>
@ -13,17 +15,14 @@
</div> </div>
</div> </div>
<a class='icon-link'> <a class='icon-link'>
<span class='icon'>{{ Icon('info') }}</span> {{ Icon('info') }}
{{ "portfolios.admin.settings_info" | translate }} {{ "portfolios.admin.settings_info" | translate }}
</a> </a>
</div> </div>
{% if not portfolio.members %} {% if not portfolio.members %}
<p>There are currently no members in this Portfolio.</p> <p>There are currently no members in this Portfolio.</p>
{% else %} {% else %}
<table> <table>
<thead> <thead>
@ -47,19 +46,24 @@
</table> </table>
{% if user_can(permissions.EDIT_PORTFOLIO_USERS) %}
<div class="members-table-footer">
<a class='icon-link'>
{{ "portfolios.admin.add_member" | translate }}
{{ Icon('plus-circle-solid') }}
</a>
<input type='submit' class='usa-button usa-button-primary' value='{{ "Save" }}' />
</div>
{% endif %} {% endif %}
</form> </form>
<div class="members-table-footer">
<div class="action-group">
{% if user_can(permissions.EDIT_PORTFOLIO_USERS) %}
<input
type='submit'
form="member-perms"
class='usa-button usa-button-primary'
value='Save' />
{% endif %}
{% if user_can(permissions.CREATE_PORTFOLIO_USERS) %}
{% include "fragments/admin/add_new_portfolio_member.html" %}
{% endif %}
</div>
</div> </div>
{% endif %} </div>
</section> </section>

View File

@ -3,13 +3,12 @@
{% from "components/pagination.html" import Pagination %} {% from "components/pagination.html" import Pagination %}
{% from "components/icon.html" import Icon %} {% from "components/icon.html" import Icon %}
{% from "components/text_input.html" import TextInput %} {% from "components/text_input.html" import TextInput %}
{% from "components/multi_step_modal_form.html" import MultiStepModalForm %}
{% set secondary_breadcrumb = "navigation.portfolio_navigation.portfolio_admin" | translate %} {% set secondary_breadcrumb = "navigation.portfolio_navigation.portfolio_admin" | translate %}
{% block portfolio_content %} {% block portfolio_content %}
{% include "fragments/flash.html" %}
<div v-cloak class="portfolio-admin portfolio-content"> <div v-cloak class="portfolio-admin portfolio-content">
<div class="panel"> <div class="panel">

View File

@ -134,6 +134,7 @@ forms:
new_member: new_member:
dod_id_label: DOD ID dod_id_label: DOD ID
email_label: Email Address email_label: Email Address
phone_number_label: Phone Number (Optional)
first_name_label: First Name first_name_label: First Name
last_name_label: Last Name last_name_label: Last Name
portfolio_role_description: 'The portfolio role controls whether a member is permitted to organize a portfolio into applications and environments, add members to this portfolio, and view billing information.' portfolio_role_description: 'The portfolio role controls whether a member is permitted to organize a portfolio into applications and environments, add members to this portfolio, and view billing information.'
@ -568,7 +569,9 @@ portfolios:
portfolio_members_subheading: These members have different levels of access to the portfolio. portfolio_members_subheading: These members have different levels of access to the portfolio.
settings_info: Learn more about these settings settings_info: Learn more about these settings
add_member: Add a New Member add_member: Add a New Member
permissions_info: Learn more about these permissions
activity_log_title: Activity Log activity_log_title: Activity Log
add_new_member: Add a New Member
members: members:
archive_button: Archive User archive_button: Archive User
permissions: permissions: