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():
g.current_user = None
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.Authorization = Authorization
g.Permissions = Permissions

View File

@ -1,10 +1,10 @@
from wtforms.fields import StringField, FormField, FieldList
from wtforms.fields.html5 import EmailField
from wtforms.validators import Required, Email, Length
from wtforms.fields.html5 import EmailField, TelField
from wtforms.validators import Required, Email, Length, Optional
from atst.domain.permission_sets import PermissionSets
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.utils.localization import translate
@ -71,6 +71,10 @@ class NewForm(PermissionsForm):
email = EmailField(
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(
translate("forms.new_member.dod_id_label"),
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.common import Paginator
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.domain.permission_sets import PermissionSets
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]
portfolio_form = PortfolioForm(data={"name": portfolio.name})
member_perms_form = MembersPermissionsForm(
member_perms_form = member_forms.MembersPermissionsForm(
data={"members_permissions": members_data}
)
return render_template(
@ -71,6 +71,7 @@ def render_admin_page(portfolio, form=None):
form=form,
portfolio_form=portfolio_form,
member_perms_form=member_perms_form,
member_form=member_forms.NewForm(),
portfolio=portfolio,
audit_events=audit_events,
user=g.current_user,

View File

@ -92,7 +92,12 @@ def create_member(portfolio_id):
flash("new_portfolio_member", new_member=member, portfolio=portfolio)
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:
return render_template(

View File

@ -22,10 +22,9 @@ MESSAGES = {
"category": "success",
},
"new_portfolio_member": {
"title_template": "Member added successfully",
"title_template": "Success!",
"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><a href="{{ url_for('portfolios.update_member', portfolio_id=portfolio.id, member_id=new_member.user_id) }}">Add environment access.</a></p>
<p>You have successfully invited {{ new_member.user_name }} to the portfolio admin.</p>
""",
"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,
noMaxWidth: String,
optional: Boolean,
},
data: function() {
@ -64,6 +65,13 @@ export default {
}
},
created: function() {
this.$root.$emit('field-mount', {
name: this.name,
optional: this.optional,
})
},
methods: {
// When user types a character
onInput: function(e) {
@ -82,7 +90,9 @@ export default {
},
onBlur: function(e) {
if (!(this.optional && e.target.value === '')) {
this._checkIfValid({ value: e.target.value.trim(), invalidate: true })
}
this.value = e.target.value.trim()
if (this.validation === 'dollars') {
@ -97,6 +107,8 @@ export default {
if (!this.modified && this.initialErrors && this.initialErrors.length) {
valid = false
} else if (this.optional && value === '') {
valid = true
}
if (this.modified) {

View File

@ -18,6 +18,7 @@ import toggler from './components/toggler'
import NewApplication from './components/forms/new_application'
import EditEnvironmentRole from './components/forms/edit_environment_role'
import EditApplicationRoles from './components/forms/edit_application_roles'
import MultiStepModalForm from './components/forms/multi_step_modal_form'
import funding from './components/forms/funding'
import uploadinput from './components/upload_input'
import Modal from './mixins/modal'
@ -59,6 +60,7 @@ const app = new Vue({
LocalDatetime,
EditEnvironmentRole,
EditApplicationRoles,
MultiStepModalForm,
ConfirmationPopover,
funding,
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 {
thead {
th:first-child {
padding-left: 3 * $gap;
@ -294,6 +295,25 @@
float: right;
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 {

View File

@ -1,8 +1,8 @@
{% 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 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__dialog' role='dialog' aria-modal='true'>
<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
{% 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 %}
key='{{ field.name }}'>
key='{{ field.name }}'
>
<div
v-bind:class="['usa-input', { 'usa-input--error': showError, 'usa-input--success': showValid }]">

View File

@ -13,7 +13,8 @@
disabled=False,
initial_value='',
classes='',
noMaxWidth=False) -%}
noMaxWidth=False,
optional=False) -%}
<textinput
v-cloak
@ -23,6 +24,7 @@
{% 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 field.errors %}v-bind:initial-errors='{{ field.errors | list }}'{% endif %}
v-bind:optional={{ optional|lower }}
key='{{ field.name }}'
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/options_input.html" import OptionsInput %}
<section class="member-list">
<section class="member-list" id="portfolio-members">
<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='left'>
<div class='h3'>{{ "portfolios.admin.portfolio_members_title" | translate }}</div>
@ -13,17 +15,14 @@
</div>
</div>
<a class='icon-link'>
<span class='icon'>{{ Icon('info') }}</span>
{{ Icon('info') }}
{{ "portfolios.admin.settings_info" | translate }}
</a>
</div>
{% if not portfolio.members %}
<p>There are currently no members in this Portfolio.</p>
{% else %}
<table>
<thead>
@ -47,19 +46,24 @@
</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 %}
</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>
{% endif %}
</div>
</section>

View File

@ -3,13 +3,12 @@
{% from "components/pagination.html" import Pagination %}
{% from "components/icon.html" import Icon %}
{% 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 %}
{% block portfolio_content %}
{% include "fragments/flash.html" %}
<div v-cloak class="portfolio-admin portfolio-content">
<div class="panel">

View File

@ -134,6 +134,7 @@ forms:
new_member:
dod_id_label: DOD ID
email_label: Email Address
phone_number_label: Phone Number (Optional)
first_name_label: First 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.'
@ -568,7 +569,9 @@ portfolios:
portfolio_members_subheading: These members have different levels of access to the portfolio.
settings_info: Learn more about these settings
add_member: Add a New Member
permissions_info: Learn more about these permissions
activity_log_title: Activity Log
add_new_member: Add a New Member
members:
archive_button: Archive User
permissions: