parent
d63ec8c6b0
commit
fe735b8048
25 changed files with 784 additions and 614 deletions
|
@ -866,6 +866,14 @@ a:focus {
|
|||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.co-dialog .co-single-field-dialog {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.co-dialog .co-single-field-dialog input {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.co-step-bar .co-step-element {
|
||||
cursor: default;
|
||||
display: inline-block;
|
||||
|
@ -1370,3 +1378,46 @@ a:focus {
|
|||
float: none;
|
||||
}
|
||||
}
|
||||
|
||||
.co-list-table tr td:first-child {
|
||||
font-weight: bold;
|
||||
padding-right: 10px;
|
||||
vertical-align: top;
|
||||
width: 120px;
|
||||
padding-left: 0px;
|
||||
}
|
||||
|
||||
.co-list-table tr td {
|
||||
padding: 10px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.co-list-table .help-text {
|
||||
margin-top: 6px;
|
||||
font-size: 14px;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.co-modify-link:after {
|
||||
font-family: FontAwesome;
|
||||
content: "\f054";
|
||||
color: #ccc;
|
||||
vertical-align: middle;
|
||||
display: inline-block;
|
||||
margin-left: 10px;
|
||||
font-size: 10px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.co-option-table tr td:first-child {
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
padding-top: 0px;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.co-option-table tr td:last-child {
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
|
||||
|
|
32
static/css/directives/ui/billing-management-panel.css
Normal file
32
static/css/directives/ui/billing-management-panel.css
Normal file
|
@ -0,0 +1,32 @@
|
|||
.billing-management-panel-element .credit-card-image {
|
||||
width: 33px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.billing-management-panel-element .credit-card-number {
|
||||
display: inline-block;
|
||||
font-size: 16px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.billing-management-panel-element .receipt-form {
|
||||
padding-top: 20px;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.billing-management-panel-element .sub-usage {
|
||||
margin-bottom: 10px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.billing-management-panel-element .sub-usage .fa {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.billing-management-panel-element .sub-usage .red {
|
||||
color: #D64456;
|
||||
}
|
||||
|
||||
.billing-management-panel-element .sub-usage .yellow {
|
||||
color: #FCA657;
|
||||
}
|
|
@ -1,15 +0,0 @@
|
|||
.billing-options .settings-option {
|
||||
padding: 4px;
|
||||
font-size: 18px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.billing-options .settings-option label {
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.billing-options .settings-option .settings-description {
|
||||
font-size: 16px;
|
||||
color: #888;
|
||||
padding-left: 26px;
|
||||
}
|
|
@ -2,6 +2,10 @@
|
|||
padding: 10px;
|
||||
}
|
||||
|
||||
.convert-user-to-org .convert-form {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.convert-user-to-org .convert-form h3 {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
@ -67,3 +71,7 @@
|
|||
.convert-user-to-org .form-group-content .co-table {
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
.convert-user-to-org .co-option-table {
|
||||
margin-top: 12px;
|
||||
}
|
3
static/css/pages/billing.css
Normal file
3
static/css/pages/billing.css
Normal file
|
@ -0,0 +1,3 @@
|
|||
.billing-page .co-main-content-panel {
|
||||
padding: 40px;
|
||||
}
|
|
@ -7,3 +7,8 @@
|
|||
margin-bottom: 20px;
|
||||
margin-top: 0px;
|
||||
}
|
||||
|
||||
.org-view .settings-section {
|
||||
margin-bottom: 50px;
|
||||
}
|
||||
|
||||
|
|
|
@ -8,27 +8,15 @@
|
|||
}
|
||||
|
||||
.user-view h3 {
|
||||
margin-bottom: 20px;
|
||||
margin-top: 0px;
|
||||
}
|
||||
|
||||
.user-view .section-description-header {
|
||||
padding-left: 40px;
|
||||
position: relative;
|
||||
margin-bottom: 20px;
|
||||
min-height: 50px;
|
||||
}
|
||||
|
||||
.user-view .section-description-header:before {
|
||||
font-family: FontAwesome;
|
||||
content: "\f05a";
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
left: 6px;
|
||||
font-size: 27px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.user-view .user-settings-form .row {
|
||||
padding: 10px;
|
||||
margin: 0px;
|
||||
|
@ -53,3 +41,6 @@
|
|||
box-shadow: none;
|
||||
}
|
||||
|
||||
.user-view .settings-section {
|
||||
margin-bottom: 50px;
|
||||
}
|
||||
|
|
|
@ -449,48 +449,6 @@ i.toggle-icon:hover {
|
|||
visibility: hidden;
|
||||
}
|
||||
|
||||
|
||||
.billing-options-element .current-card {
|
||||
font-size: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
|
||||
.billing-options-element .current-card .no-card-outline {
|
||||
display: inline-block;
|
||||
width: 73px;
|
||||
height: 44px;
|
||||
vertical-align: middle;
|
||||
margin-right: 10px;
|
||||
border: 1px dashed #aaa;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.billing-options-element .current-card .last4 {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.billing-options-element .current-card .last4 b {
|
||||
color: black;
|
||||
}
|
||||
|
||||
.billing-options-element .current-card .expires:before {
|
||||
content: "Expires:";
|
||||
color: #aaa;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
|
||||
.billing-options-element .current-card .expires {
|
||||
margin-left: 20px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.billing-options-element .current-card img {
|
||||
margin-right: 10px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.organization-header-element {
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
|
@ -4077,24 +4035,12 @@ i.rocket-icon {
|
|||
}
|
||||
|
||||
.section-description-header {
|
||||
padding-left: 40px;
|
||||
position: relative;
|
||||
margin-bottom: 20px;
|
||||
margin-bottom: 10px;
|
||||
min-height: 50px;
|
||||
border-bottom: 1px solid #eee;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.section-description-header:before {
|
||||
font-family: FontAwesome;
|
||||
content: "\f05a";
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
left: 6px;
|
||||
font-size: 27px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.nvtooltip h3 {
|
||||
margin: 0;
|
||||
padding: 4px 14px;
|
||||
|
|
80
static/directives/billing-management-panel.html
Normal file
80
static/directives/billing-management-panel.html
Normal file
|
@ -0,0 +1,80 @@
|
|||
<div class="billing-management-panel-element">
|
||||
<div class="cor-loader-inline" ng-show="updating"></div>
|
||||
<div ng-show="!updating">
|
||||
<table class="co-list-table">
|
||||
<tr>
|
||||
<td>Current Plan:</td>
|
||||
<td>
|
||||
<div class="sub-usage" ng-if="subscription.usedPrivateRepos > currentPlan.privateRepos">
|
||||
<i class="fa fa-exclamation-triangle red"></i> <strong>{{ subscription.usedPrivateRepos }}</strong> private repositories exceeds the amount allowed by your plan. Upgrade your plan to avoid service disruptions.
|
||||
</div>
|
||||
|
||||
<div class="sub-usage" ng-if="subscription.usedPrivateRepos == currentPlan.privateRepos">
|
||||
<i class="fa fa-exclamation-triangle yellow"></i> <strong>{{ subscription.usedPrivateRepos }}</strong> private repositories is the maximum allowed by your plan. Upgrade your plan to create more private repositories.
|
||||
</div>
|
||||
|
||||
<a class="co-modify-link" ng-href="{{ getEntityPrefix() }}/billing">{{ currentPlan.privateRepos }} private repositories</a>
|
||||
<div class="help-text">Up to {{ currentPlan.privateRepos }} private repositories, unlimited public repositories</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr ng-show="currentCard">
|
||||
<td>Credit Card:</td>
|
||||
<td>
|
||||
<img class="credit-card-image" ng-src="/static/img/creditcards/{{ getCreditImage(currentCard) }}">
|
||||
<span class="credit-card-number">
|
||||
••••
|
||||
••••
|
||||
••••
|
||||
{{ currentCard.last4 }}
|
||||
</span>
|
||||
<a class="co-modify-link" ng-click="changeCreditCard()">Change card</a>
|
||||
<div class="help-text">Expires {{ currentCard.exp_month }}/{{ currentCard.exp_year }}</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Invoices:</td>
|
||||
<td>
|
||||
<a ng-href="{{ getEntityPrefix() }}/billing/invoices">View Invoices</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Receipts:</td>
|
||||
<td>
|
||||
<a class="co-modify-link" ng-click="showChangeReceipts()" ng-show="!invoice_email">Do not email after successful charges</a>
|
||||
<a class="co-modify-link" ng-click="showChangeReceipts()" ng-show="invoice_email">Email receipts to {{ invoice_email_address }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Change receipts dialog -->
|
||||
<div class="cor-confirm-dialog"
|
||||
dialog-context="changeReceiptsInfo"
|
||||
dialog-action="changeReceipts(info, callback)"
|
||||
dialog-title="Receipts Settings"
|
||||
dialog-action-title="Update Setting"
|
||||
dialog-form="context.receiptform">
|
||||
<form class="receipt-form" name="context.receiptform">
|
||||
<table class="co-option-table">
|
||||
<tr>
|
||||
<td><input type="radio" id="emailReceiptNo" ng-model="changeReceiptsInfo.sendOption" ng-value="false"></td>
|
||||
<td>
|
||||
<label for="emailReceiptNo">Do not send email receipts</label>
|
||||
<div class="help-text">Log into your account to view invoices</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><input type="radio" id="emailReceiptYes" ng-model="changeReceiptsInfo.sendOption" ng-value="true"></td>
|
||||
<td>
|
||||
<label for="emailReceiptYes">Send receipts via email</label>
|
||||
<div class="help-text">
|
||||
After every successful charge send an email to:
|
||||
<div style="margin-top: 6px;"><input type="email" class="form-control" ng-model="changeReceiptsInfo.address" required></div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
|
@ -1,65 +0,0 @@
|
|||
<div class="billing-options-element">
|
||||
<!-- Credit Card -->
|
||||
<div style="margin-bottom: 20px">
|
||||
<div class="panel-title">
|
||||
Credit Card
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="cor-loader-inline" ng-show="!currentCard || changingCard"></div>
|
||||
<div class="current-card" ng-show="currentCard && !changingCard">
|
||||
<div class="alert alert-warning" ng-if="currentCard.last4 && isExpiringSoon(currentCard)">
|
||||
Your current credit card is expiring soon!
|
||||
</div>
|
||||
|
||||
<img ng-src="{{ '/static/img/creditcards/' + getCreditImage(currentCard) }}" ng-show="currentCard.last4">
|
||||
<span class="no-card-outline" ng-show="!currentCard.last4"></span>
|
||||
|
||||
<span class="last4" ng-show="currentCard.last4">****-****-****-<b>{{ currentCard.last4 }}</b></span>
|
||||
<span class="expires" ng-show="currentCard.last4">
|
||||
{{ currentCard.exp_month }} / {{ currentCard.exp_year }}
|
||||
</span>
|
||||
|
||||
<span class="not-found" ng-show="!currentCard.last4">No credit card found</span>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary" ng-show="currentCard && !changingCard" ng-click="changeCard()">
|
||||
Change Credit Card
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Options -->
|
||||
<div style="margin-bottom: 20px">
|
||||
<div class="panel-title">
|
||||
Billing Receipts
|
||||
<div class="cor-loader-inline" ng-show="working"></div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="settings-option">
|
||||
<input id="invoiceEmail" type="checkbox" ng-model="invoice_email">
|
||||
<label for="invoiceEmail">Send Receipt Emails</label>
|
||||
<div class="settings-description">
|
||||
If checked, a receipt email will be sent to <a ng-click="changeInvoiceEmailAddress()">{{ obj.invoice_email_address || obj.email }}</a> on every successful charge
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal message dialog -->
|
||||
<div class="modal fade" id="cannotchangecardModal">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button>
|
||||
<h4 class="modal-title">Cannot change credit card</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
Your credit card could not be changed
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div><!-- /.modal-content -->
|
||||
</div><!-- /.modal-dialog -->
|
||||
</div><!-- /.modal -->
|
||||
</div>
|
|
@ -1,112 +1,108 @@
|
|||
<div class="convert-user-to-org-element">
|
||||
<!-- Step 0 -->
|
||||
<div ng-show="convertStep == 0">
|
||||
<div ng-show="user.organizations.length > 0">
|
||||
Cannot convert this account into an organization, as it is a member of {{user.organizations.length}} other
|
||||
organization{{user.organizations.length > 1 ? 's' : ''}}.
|
||||
<br><br>
|
||||
Please leave the following organizations first:
|
||||
<ul class="org-list">
|
||||
<li ng-repeat="org in user.organizations">
|
||||
<span class="avatar" size="avatarSize || 16" data="org.avatar"></span>
|
||||
<a href="/organization/{{ org.name }}">{{ org.name }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="co-dialog modal fade" id="convertAccountModal">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button>
|
||||
<h4 class="modal-title">Change Account Type</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<!-- Step 0 -->
|
||||
<div ng-show="convertStep == 0">
|
||||
<div ng-show="user.organizations.length > 0">
|
||||
This account cannot be converted into an organization, as it is a member of {{user.organizations.length}} other
|
||||
organization{{user.organizations.length > 1 ? 's' : ''}}.
|
||||
<br><br>
|
||||
Please leave the following organizations first:
|
||||
<ul class="org-list">
|
||||
<li ng-repeat="org in user.organizations">
|
||||
<span class="avatar" size="avatarSize || 16" data="org.avatar"></span>
|
||||
<a href="/organization/{{ org.name }}">{{ org.name }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div ng-show="user.organizations.length == 0">
|
||||
<button class="btn btn-primary" ng-click="showConvertForm()">Start conversion process <i class="fa fa-arrow-circle-right" aria-hidden="true"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 1 -->
|
||||
<div class="convert-form" ng-show="convertStep == 1">
|
||||
Fill out the form below to convert your current user account into an organization. Your existing repositories will be maintained under the
|
||||
namespace. All <strong>direct</strong> permissions delegated to {{ user.username }} will be deleted.
|
||||
|
||||
<form method="post" name="convertForm" id="convertForm" ng-submit="convertToOrg()">
|
||||
<div class="form-group">
|
||||
<label for="orgName">Organization Name</label>
|
||||
<div class="form-group-content">
|
||||
<div class="existing-data">
|
||||
<span class="avatar" size="24" data="user.avatar"></span>
|
||||
<span class="username">{{ user.username }}</span>
|
||||
<div ng-show="user.organizations.length == 0">
|
||||
<table class="co-option-table">
|
||||
<tr>
|
||||
<td><input type="radio" id="accountTypeI" ng-model="accountType" value="user"></td>
|
||||
<td>
|
||||
<label for="accountTypeI">Individual account (current)</label>
|
||||
<div class="help-text">Single account with multiple repositories</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><input type="radio" id="accountTypeO" ng-model="accountType" value="organization"></td>
|
||||
<td>
|
||||
<label for="accountTypeO">Organization</label>
|
||||
<div class="help-text">Multiple users and teams that share access and billing under a single namespace</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<span class="description">This will continue to be the namespace for your repositories</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="orgName">Admin User</label>
|
||||
<div class="form-group-content">
|
||||
<input id="adminUsername" name="adminUsername" type="text" class="form-control" placeholder="Admin Username"
|
||||
ng-model="org.adminUser" required autofocus>
|
||||
<input id="adminPassword" name="adminPassword" type="password" class="form-control" placeholder="Admin Password"
|
||||
ng-model="org.adminPassword" required>
|
||||
<span class="description">
|
||||
The username and password for the account that will become an administrator of the organization.
|
||||
Note that this account <b>must be a separate registered account</b> from the account that you are
|
||||
trying to convert, and <b>must already exist</b>.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Step 1 -->
|
||||
<div class="convert-form" ng-show="convertStep == 1">
|
||||
Fill out the form below to convert your current user account into an organization. Your existing repositories will be maintained under the
|
||||
namespace. All <strong>direct</strong> permissions delegated to {{ user.username }} will be deleted.
|
||||
|
||||
<!-- Plans Table -->
|
||||
<div class="form-group plan-group" quay-require="['BILLING']">
|
||||
<label>Organization Plan</label>
|
||||
<div class="form-group-content">
|
||||
<div class="plans-table" plans="orgPlans" current-plan="org.plan"></div>
|
||||
<span class="description">The billing plan for the new organization. If private repositories are unneeded, select "Open Source".</span>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" name="convertForm" id="convertForm" ng-submit="nextStep()">
|
||||
<div class="form-group">
|
||||
<label for="orgName">Organization Name</label>
|
||||
<div class="form-group-content">
|
||||
<div class="existing-data">
|
||||
<span class="avatar" size="24" data="user.avatar"></span>
|
||||
<span class="username">{{ user.username }}</span>
|
||||
</div>
|
||||
<span class="description">This will continue to be the namespace for your repositories</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="button-bar">
|
||||
<button class="btn btn-large btn-danger" type="submit"
|
||||
ng-disabled="convertForm.$invalid || (Features.BILLING && !org.plan)"
|
||||
analytics-on analytics-event="convert_to_organization">
|
||||
Convert To Organization
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="orgName">Admin User</label>
|
||||
<div class="form-group-content">
|
||||
<input id="adminUsername" name="adminUsername" type="text" class="form-control" placeholder="Admin Username"
|
||||
ng-model="org.adminUser" required autofocus>
|
||||
<input id="adminPassword" name="adminPassword" type="password" class="form-control" placeholder="Admin Password"
|
||||
ng-model="org.adminPassword" required>
|
||||
<span class="description">
|
||||
The username and password for the account that will become an administrator of the organization.
|
||||
Note that this account <b>must be a separate registered account</b> from the account that you are
|
||||
trying to convert, and <b>must already exist</b>.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Step 2 -->
|
||||
<div class="convert-form" ng-show="convertStep == 2">
|
||||
Please select the billing plan to use for the new organization. Select "Open Source" to create an organization without
|
||||
private repositories.
|
||||
<div class="plans-table" plans="orgPlans" current-plan="org.plan"></div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3 (conversion) -->
|
||||
<div class="convert-form" ng-show="convertStep == 3">
|
||||
<div class="cor-loader"></div>
|
||||
</div>
|
||||
|
||||
<!-- Modal message dialog -->
|
||||
<div class="modal fade" id="cannotconvertModal">
|
||||
<div class="modal-dialog co-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button>
|
||||
<h4 class="modal-title">Cannot convert account</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
Your account could not be converted. Please try again in a moment.
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
|
||||
<div class="modal-footer" ng-show="convertStep < 3">
|
||||
<button class="btn btn-default" ng-show="convertStep == 0 && accountType == 'user'" data-dismiss="modal">Close</button>
|
||||
<button class="btn btn-primary" ng-show="convertStep == 0 && accountType == 'organization'" ng-click="showConvertForm()">Convert Account</button>
|
||||
<button class="btn btn-primary" ng-show="convertStep == 1" ng-disabled="convertForm.$invalid" ng-click="nextStep()">
|
||||
<span ng-if="Features.BILLING">Choose billing</span>
|
||||
<span ng-if="!Features.BILLING">Convert Account</span>
|
||||
</button>
|
||||
<button class="btn btn-primary" ng-show="convertStep == 2" ng-disabled="!org.plan" ng-click="performConversion()">
|
||||
Convert Account
|
||||
</button>
|
||||
</div>
|
||||
</div><!-- /.modal-content -->
|
||||
</div><!-- /.modal-dialog -->
|
||||
</div><!-- /.modal -->
|
||||
|
||||
|
||||
<!-- Modal message dialog -->
|
||||
<div class="modal co-modal fade" id="reallyconvertModal">
|
||||
<div class="modal-dialog co-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button>
|
||||
<h4 class="modal-title">Convert to organization?</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="co-alert co-alert-danger">You will not be able to login to this account once converted!</div>
|
||||
<div>Are you <b>absolutely sure</b> you would like to convert this account to an organization? Once done, there is no going back.</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-danger" data-dismiss="modal" ng-click="reallyConvert()">Absolutely: Convert Now</button>
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
|
||||
</div>
|
||||
</div><!-- /.modal-content -->
|
||||
</div><!-- /.modal-dialog -->
|
||||
</div><!-- /.modal -->
|
||||
|
||||
</div>
|
||||
|
|
|
@ -14,7 +14,7 @@
|
|||
<span ng-transclude/>
|
||||
</div>
|
||||
<div class="modal-footer" ng-show="!working">
|
||||
<button type="button" class="btn btn-primary" ng-click="performAction()">
|
||||
<button type="button" class="btn btn-primary" ng-click="performAction()" ng-disabled="dialogForm && dialogForm.$invalid">
|
||||
{{ dialogActionTitle }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
|
||||
|
|
|
@ -121,9 +121,21 @@ quayApp.config(['$routeProvider', '$locationProvider', 'pages', function($routeP
|
|||
// Organization View Application
|
||||
.route('/organization/:orgname/application/:clientid', 'manage-application')
|
||||
|
||||
// View Organization Billing
|
||||
.route('/organization/:orgname/billing', 'billing')
|
||||
|
||||
// View Organization Billing Invoices
|
||||
.route('/organization/:orgname/billing/invoices', 'invoices')
|
||||
|
||||
// View User
|
||||
.route('/user/:username', 'user-view')
|
||||
|
||||
// View User Billing
|
||||
.route('/user/:username/billing', 'billing')
|
||||
|
||||
// View User Billing Invoices
|
||||
.route('/user/:username/billing/invoices', 'invoices')
|
||||
|
||||
// Sign In
|
||||
.route('/signin/', 'signin')
|
||||
|
||||
|
|
|
@ -172,10 +172,12 @@ angular.module("core-ui", [])
|
|||
scope: {
|
||||
'dialogTitle': '@dialogTitle',
|
||||
'dialogActionTitle': '@dialogActionTitle',
|
||||
'dialogForm': '=dialogForm',
|
||||
|
||||
'dialogContext': '=dialogContext',
|
||||
'dialogAction': '&dialogAction'
|
||||
},
|
||||
|
||||
controller: function($rootScope, $scope, $element) {
|
||||
$scope.working = false;
|
||||
|
||||
|
@ -195,6 +197,10 @@ angular.module("core-ui", [])
|
|||
};
|
||||
|
||||
$scope.show = function() {
|
||||
if ($scope.dialogForm) {
|
||||
$scope.dialogForm.$setPristine();
|
||||
}
|
||||
|
||||
$scope.working = false;
|
||||
$element.find('.modal').modal({});
|
||||
};
|
||||
|
|
140
static/js/directives/ui/billing-management-panel.js
Normal file
140
static/js/directives/ui/billing-management-panel.js
Normal file
|
@ -0,0 +1,140 @@
|
|||
/**
|
||||
* An element which displays the billing options for a user or an organization.
|
||||
*/
|
||||
angular.module('quay').directive('billingManagementPanel', function () {
|
||||
var directiveDefinitionObject = {
|
||||
priority: 0,
|
||||
templateUrl: '/static/directives/billing-management-panel.html',
|
||||
replace: false,
|
||||
transclude: false,
|
||||
restrict: 'C',
|
||||
scope: {
|
||||
'user': '=user',
|
||||
'organization': '=organization',
|
||||
'isEnabled': '=isEnabled'
|
||||
},
|
||||
controller: function($scope, $element, PlanService, ApiService) {
|
||||
$scope.currentCard = null;
|
||||
$scope.subscription = null;
|
||||
$scope.updating = true;
|
||||
$scope.changeReceiptsInfo = null;
|
||||
$scope.context = {};
|
||||
|
||||
var setSubscription = function(sub) {
|
||||
$scope.subscription = sub;
|
||||
|
||||
// Load the plan info.
|
||||
PlanService.getPlan(sub['plan'], function(plan) {
|
||||
$scope.currentPlan = plan;
|
||||
|
||||
if (!sub.hasSubscription) {
|
||||
$scope.updating = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Load credit card information.
|
||||
PlanService.getCardInfo($scope.organization ? $scope.organization.name : null, function(card) {
|
||||
$scope.currentCard = card;
|
||||
$scope.updating = false;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
var update = function() {
|
||||
if (!$scope.isEnabled || !($scope.user || $scope.organization)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$scope.entity = $scope.user ? $scope.user : $scope.organization;
|
||||
$scope.invoice_email = $scope.entity.invoice_email;
|
||||
$scope.invoice_email_address = $scope.entity.invoice_email_address || $scope.entity.email;
|
||||
|
||||
$scope.updating = true;
|
||||
|
||||
// Load plan information.
|
||||
PlanService.getSubscription($scope.organization, setSubscription, function() {
|
||||
setSubscription({ 'plan': PlanService.getFreePlan() });
|
||||
});
|
||||
};
|
||||
|
||||
// Listen to plan changes.
|
||||
PlanService.registerListener(this, function(plan) {
|
||||
if (plan && plan.price > 0) {
|
||||
update();
|
||||
}
|
||||
});
|
||||
|
||||
$scope.$on('$destroy', function() {
|
||||
PlanService.unregisterListener(this);
|
||||
});
|
||||
|
||||
$scope.$watch('isEnabled', update);
|
||||
$scope.$watch('organization', update);
|
||||
$scope.$watch('user', update);
|
||||
|
||||
$scope.getEntityPrefix = function() {
|
||||
if ($scope.organization) {
|
||||
return '/organization/' + $scope.organization.name;
|
||||
} else {
|
||||
return '/user/' + $scope.user.username;
|
||||
}
|
||||
};
|
||||
|
||||
$scope.changeCreditCard = function() {
|
||||
var callbacks = {
|
||||
'opened': function() { },
|
||||
'closed': function() { },
|
||||
'started': function() { },
|
||||
'success': function(resp) {
|
||||
$scope.currentCard = resp.card;
|
||||
update();
|
||||
},
|
||||
'failure': function(resp) {
|
||||
if (!PlanService.isCardError(resp)) {
|
||||
bootbox.alert('Could not change credit card. Please try again later.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
PlanService.changeCreditCard($scope, $scope.organization ? $scope.organization.name : null, callbacks);
|
||||
};
|
||||
|
||||
$scope.getCreditImage = function(creditInfo) {
|
||||
if (!creditInfo || !creditInfo.type) { return 'credit.png'; }
|
||||
|
||||
var kind = creditInfo.type.toLowerCase() || 'credit';
|
||||
var supported = {
|
||||
'american express': 'amex',
|
||||
'credit': 'credit',
|
||||
'diners club': 'diners',
|
||||
'discover': 'discover',
|
||||
'jcb': 'jcb',
|
||||
'mastercard': 'mastercard',
|
||||
'visa': 'visa'
|
||||
};
|
||||
|
||||
kind = supported[kind] || 'credit';
|
||||
return kind + '.png';
|
||||
};
|
||||
|
||||
$scope.changeReceipts = function(info, callback) {
|
||||
$scope.entity['invoice_email'] = info['sendOption'] || false;
|
||||
$scope.entity['invoice_email_address'] = info['address'] || $scope.entity.email;
|
||||
|
||||
var errorHandler = ApiService.errorDisplay('Could not change billing options', callback);
|
||||
ApiService.changeDetails($scope.organization, $scope.entity).then(function(resp) {
|
||||
callback(true);
|
||||
update();
|
||||
}, errorHandler);
|
||||
};
|
||||
|
||||
$scope.showChangeReceipts = function() {
|
||||
$scope.changeReceiptsInfo = {
|
||||
'sendOption': $scope.invoice_email,
|
||||
'address': $scope.invoice_email_address
|
||||
};
|
||||
};
|
||||
}
|
||||
};
|
||||
return directiveDefinitionObject;
|
||||
});
|
|
@ -1,130 +0,0 @@
|
|||
/**
|
||||
* An element which displays the billing options for a user or an organization.
|
||||
*/
|
||||
angular.module('quay').directive('billingOptions', function () {
|
||||
var directiveDefinitionObject = {
|
||||
priority: 0,
|
||||
templateUrl: '/static/directives/billing-options.html',
|
||||
replace: false,
|
||||
transclude: false,
|
||||
restrict: 'C',
|
||||
scope: {
|
||||
'user': '=user',
|
||||
'organization': '=organization'
|
||||
},
|
||||
controller: function($scope, $element, PlanService, ApiService) {
|
||||
$scope.invoice_email = false;
|
||||
$scope.currentCard = null;
|
||||
|
||||
// Listen to plan changes.
|
||||
PlanService.registerListener(this, function(plan) {
|
||||
if (plan && plan.price > 0) {
|
||||
update();
|
||||
}
|
||||
});
|
||||
|
||||
$scope.$on('$destroy', function() {
|
||||
PlanService.unregisterListener(this);
|
||||
});
|
||||
|
||||
$scope.isExpiringSoon = function(cardInfo) {
|
||||
var current = new Date();
|
||||
var expires = new Date(cardInfo.exp_year, cardInfo.exp_month, 1);
|
||||
var difference = expires - current;
|
||||
return difference < (60 * 60 * 24 * 60 * 1000 /* 60 days */);
|
||||
};
|
||||
|
||||
$scope.changeInvoiceEmailAddress = function() {
|
||||
bootbox.prompt('Enter the email address for receiving receipts', function(email) {
|
||||
// Copied from Angular.
|
||||
var EMAIL_REGEXP = /^[a-z0-9!#$%&'*+/=?^_`{|}~.-]+@[a-z0-9-]+(\.[a-z0-9-]+)*$/i;
|
||||
if (!email || !EMAIL_REGEXP.test(email)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$scope.obj['invoice_email_address'] = email;
|
||||
|
||||
var errorHandler = ApiService.errorDisplay('Could not change user details');
|
||||
ApiService.changeDetails($scope.organization, $scope.obj).then(function(resp) {
|
||||
$scope.working = false;
|
||||
}, errorHandler);
|
||||
});
|
||||
};
|
||||
|
||||
$scope.changeCard = function() {
|
||||
var previousCard = $scope.currentCard;
|
||||
$scope.changingCard = true;
|
||||
var callbacks = {
|
||||
'opened': function() { $scope.changingCard = true; },
|
||||
'closed': function() { $scope.changingCard = false; },
|
||||
'started': function() { $scope.currentCard = null; },
|
||||
'success': function(resp) {
|
||||
$scope.currentCard = resp.card;
|
||||
$scope.changingCard = false;
|
||||
},
|
||||
'failure': function(resp) {
|
||||
$scope.changingCard = false;
|
||||
$scope.currentCard = previousCard;
|
||||
|
||||
if (!PlanService.isCardError(resp)) {
|
||||
$('#cannotchangecardModal').modal({});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
PlanService.changeCreditCard($scope, $scope.organization ? $scope.organization.name : null, callbacks);
|
||||
};
|
||||
|
||||
$scope.getCreditImage = function(creditInfo) {
|
||||
if (!creditInfo || !creditInfo.type) { return 'credit.png'; }
|
||||
|
||||
var kind = creditInfo.type.toLowerCase() || 'credit';
|
||||
var supported = {
|
||||
'american express': 'amex',
|
||||
'credit': 'credit',
|
||||
'diners club': 'diners',
|
||||
'discover': 'discover',
|
||||
'jcb': 'jcb',
|
||||
'mastercard': 'mastercard',
|
||||
'visa': 'visa'
|
||||
};
|
||||
|
||||
kind = supported[kind] || 'credit';
|
||||
return kind + '.png';
|
||||
};
|
||||
|
||||
var update = function() {
|
||||
if (!$scope.user && !$scope.organization) { return; }
|
||||
$scope.obj = $scope.user ? $scope.user : $scope.organization;
|
||||
$scope.invoice_email = $scope.obj.invoice_email;
|
||||
|
||||
// Load the credit card information.
|
||||
PlanService.getCardInfo($scope.organization ? $scope.organization.name : null, function(card) {
|
||||
$scope.currentCard = card;
|
||||
});
|
||||
};
|
||||
|
||||
var save = function() {
|
||||
$scope.working = true;
|
||||
|
||||
var errorHandler = ApiService.errorDisplay('Could not change user details');
|
||||
ApiService.changeDetails($scope.organization, $scope.obj).then(function(resp) {
|
||||
$scope.working = false;
|
||||
}, errorHandler);
|
||||
};
|
||||
|
||||
var checkSave = function() {
|
||||
if (!$scope.obj) { return; }
|
||||
if ($scope.obj.invoice_email != $scope.invoice_email) {
|
||||
$scope.obj.invoice_email = $scope.invoice_email;
|
||||
save();
|
||||
}
|
||||
};
|
||||
|
||||
$scope.$watch('invoice_email', checkSave);
|
||||
$scope.$watch('organization', update);
|
||||
$scope.$watch('user', update);
|
||||
}
|
||||
};
|
||||
return directiveDefinitionObject;
|
||||
});
|
|
@ -9,14 +9,29 @@ angular.module('quay').directive('convertUserToOrg', function () {
|
|||
transclude: true,
|
||||
restrict: 'C',
|
||||
scope: {
|
||||
'user': '=user'
|
||||
'info': '=info'
|
||||
},
|
||||
controller: function($scope, $element, $location, Features, PlanService, Config, ApiService, CookieService, UserService) {
|
||||
$scope.convertStep = 0;
|
||||
$scope.org = {};
|
||||
$scope.loading = false;
|
||||
$scope.user = null;
|
||||
$scope.Features = Features;
|
||||
|
||||
$scope.$watch('info', function(info) {
|
||||
if (info && info.user) {
|
||||
$scope.user = info.user;
|
||||
$scope.accountType = 'user';
|
||||
$scope.convertStep = 0;
|
||||
$('#convertAccountModal').modal({});
|
||||
}
|
||||
});
|
||||
|
||||
$scope.showConvertForm = function() {
|
||||
$scope.convertStep = 1;
|
||||
};
|
||||
|
||||
$scope.nextStep = function() {
|
||||
if (Features.BILLING) {
|
||||
PlanService.getMatchingBusinessPlan(function(plan) {
|
||||
$scope.org.plan = plan;
|
||||
|
@ -25,22 +40,19 @@ angular.module('quay').directive('convertUserToOrg', function () {
|
|||
PlanService.getPlans(function(plans) {
|
||||
$scope.orgPlans = plans;
|
||||
});
|
||||
|
||||
$scope.convertStep = 2;
|
||||
} else {
|
||||
$scope.performConversion();
|
||||
}
|
||||
|
||||
$scope.convertStep = 1;
|
||||
};
|
||||
|
||||
$scope.convertToOrg = function() {
|
||||
$('#reallyconvertModal').modal({});
|
||||
};
|
||||
|
||||
$scope.reallyConvert = function() {
|
||||
$scope.performConversion = function() {
|
||||
if (Config.AUTHENTICATION_TYPE != 'Database') { return; }
|
||||
|
||||
$scope.loading = true;
|
||||
$scope.convertStep = 3;
|
||||
|
||||
var errorHandler = ApiService.errorDisplay(function() {
|
||||
$scope.loading = false;
|
||||
$('#convertAccountModal').modal('hide');
|
||||
});
|
||||
|
||||
var data = {
|
||||
|
@ -52,6 +64,7 @@ angular.module('quay').directive('convertUserToOrg', function () {
|
|||
ApiService.convertUserToOrganization(data).then(function(resp) {
|
||||
CookieService.putPermanent('quay.namespace', $scope.user.username);
|
||||
UserService.load();
|
||||
$('#convertAccountModal').modal('hide');
|
||||
$location.path('/');
|
||||
}, errorHandler);
|
||||
};
|
||||
|
|
44
static/js/pages/billing.js
Normal file
44
static/js/pages/billing.js
Normal file
|
@ -0,0 +1,44 @@
|
|||
(function() {
|
||||
/**
|
||||
* Billing plans page.
|
||||
*/
|
||||
angular.module('quayPages').config(['pages', function(pages) {
|
||||
pages.create('billing', 'billing.html', BillingCtrl, {
|
||||
'title': 'Billing',
|
||||
'description': 'Billing',
|
||||
'newLayout': true
|
||||
});
|
||||
}]);
|
||||
|
||||
/**
|
||||
* Billing invoices page.
|
||||
*/
|
||||
angular.module('quayPages').config(['pages', function(pages) {
|
||||
pages.create('invoices', 'invoices.html', BillingCtrl, {
|
||||
'title': 'Billing Invoices',
|
||||
'description': 'Billing Invoices',
|
||||
'newLayout': true
|
||||
});
|
||||
}]);
|
||||
|
||||
|
||||
function BillingCtrl($scope, ApiService, $routeParams) {
|
||||
$scope.orgname = $routeParams['orgname'];
|
||||
$scope.username = $routeParams['username'];
|
||||
|
||||
var loadEntity = function() {
|
||||
if ($scope.orgname) {
|
||||
$scope.entityResource = ApiService.getOrganizationAsResource({'orgname': $scope.orgname}).get(function(org) {
|
||||
$scope.organization = org;
|
||||
});
|
||||
} else {
|
||||
$scope.entityResource = ApiService.getUserInformationAsResource({'username': $scope.username}).get(function(user) {
|
||||
$scope.viewuser = user;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Load the user or organization.
|
||||
loadEntity();
|
||||
}
|
||||
}());
|
|
@ -16,9 +16,10 @@
|
|||
$scope.namespace = orgname;
|
||||
$scope.showLogsCounter = 0;
|
||||
$scope.showApplicationsCounter = 0;
|
||||
$scope.showInvoicesCounter = 0;
|
||||
$scope.showBillingCounter = 0;
|
||||
$scope.showRobotsCounter = 0;
|
||||
$scope.showTeamsCounter = 0;
|
||||
$scope.changeEmailInfo = null;
|
||||
|
||||
$scope.orgScope = {
|
||||
'changingOrganization': false,
|
||||
|
@ -67,8 +68,8 @@
|
|||
$scope.showTeamsCounter++;
|
||||
};
|
||||
|
||||
$scope.showInvoices = function() {
|
||||
$scope.showInvoicesCounter++;
|
||||
$scope.showBilling = function() {
|
||||
$scope.showBillingCounter = true;
|
||||
};
|
||||
|
||||
$scope.showApplications = function() {
|
||||
|
@ -79,25 +80,27 @@
|
|||
$scope.showLogsCounter++;
|
||||
};
|
||||
|
||||
$scope.changeEmail = function() {
|
||||
UIService.hidePopover('#changeEmailForm input');
|
||||
$scope.showChangeEmail = function() {
|
||||
$scope.changeEmailInfo = {
|
||||
'email': $scope.organization.email
|
||||
};
|
||||
};
|
||||
|
||||
$scope.orgScope.changingOrganization = true;
|
||||
$scope.changeEmail = function(info, callback) {
|
||||
var params = {
|
||||
'orgname': orgname
|
||||
};
|
||||
|
||||
var data = {
|
||||
'email': $scope.orgScope.organizationEmail
|
||||
var details = {
|
||||
'email': $scope.changeEmailInfo.email
|
||||
};
|
||||
|
||||
ApiService.changeOrganizationDetails(data, params).then(function(org) {
|
||||
$scope.orgScope.changingOrganization = false;
|
||||
$scope.organization = org;
|
||||
}, function(result) {
|
||||
$scope.orgScope.changingOrganization = false;
|
||||
UIService.showFormError('#changeEmailForm input', result, 'right');
|
||||
});
|
||||
var errorDisplay = ApiService.errorDisplay('Could not change email address', callback);
|
||||
|
||||
ApiService.changeOrganizationDetails(details, params).then(function() {
|
||||
$scope.organization.email = $scope.changeEmailInfo.email;
|
||||
callback(true);
|
||||
}, errorDisplay);
|
||||
};
|
||||
}
|
||||
})();
|
|
@ -13,11 +13,13 @@
|
|||
function UserViewCtrl($scope, $routeParams, $timeout, ApiService, UserService, UIService, AvatarService, Config, ExternalLoginService) {
|
||||
var username = $routeParams.username;
|
||||
|
||||
$scope.showInvoicesCounter = 0;
|
||||
$scope.showAppsCounter = 0;
|
||||
$scope.showRobotsCounter = 0;
|
||||
$scope.changeEmailInfo = {};
|
||||
$scope.changePasswordInfo = {};
|
||||
$scope.showBillingCounter = 0;
|
||||
$scope.showLogsCounter = 0;
|
||||
|
||||
$scope.changeEmailInfo = null;
|
||||
$scope.changePasswordInfo = null;
|
||||
$scope.hasSingleSignin = ExternalLoginService.hasSingleSignin();
|
||||
$scope.context = {};
|
||||
|
||||
|
@ -54,37 +56,32 @@
|
|||
$scope.showRobotsCounter++;
|
||||
};
|
||||
|
||||
$scope.showInvoices = function() {
|
||||
$scope.showInvoicesCounter++;
|
||||
$scope.showLogs = function() {
|
||||
$scope.showLogsCounter++;
|
||||
};
|
||||
|
||||
$scope.showApplications = function() {
|
||||
$scope.showAppsCounter++;
|
||||
};
|
||||
|
||||
$scope.changePassword = function() {
|
||||
if (Config.AUTHENTICATION_TYPE != 'Database') { return; }
|
||||
$scope.showChangePassword = function() {
|
||||
$scope.changePasswordInfo = {};
|
||||
};
|
||||
|
||||
UIService.hidePopover('#changePasswordForm');
|
||||
$scope.changePasswordInfo.state = 'changing';
|
||||
$scope.changePassword = function(info, callback) {
|
||||
if (Config.AUTHENTICATION_TYPE != 'Database') { return; }
|
||||
|
||||
var data = {
|
||||
'password': $scope.changePasswordInfo.password
|
||||
};
|
||||
|
||||
var errorDisplay = ApiService.errorDisplay('Could not change password', callback);
|
||||
|
||||
ApiService.changeUserDetails(data).then(function(resp) {
|
||||
$scope.changePasswordInfo.state = 'changed';
|
||||
|
||||
// Reset the form
|
||||
delete $scope.changePasswordInfo['password']
|
||||
delete $scope.changePasswordInfo['repeatPassword']
|
||||
|
||||
// Reload the user.
|
||||
UserService.load();
|
||||
}, function(result) {
|
||||
$scope.changePasswordInfo.state = 'change-error';
|
||||
UIService.showFormError('#changePasswordForm', result);
|
||||
});
|
||||
callback(true);
|
||||
}, errorDisplay);
|
||||
};
|
||||
|
||||
$scope.generateClientToken = function() {
|
||||
|
@ -102,21 +99,32 @@
|
|||
UIService.showPasswordDialog('Enter your password to generate an encrypted version:', generateToken);
|
||||
};
|
||||
|
||||
$scope.changeEmail = function() {
|
||||
UIService.hidePopover('#changeEmailForm');
|
||||
$scope.showChangeEmail = function() {
|
||||
$scope.changeEmailInfo = {
|
||||
'email': $scope.context.viewuser.email
|
||||
};
|
||||
};
|
||||
|
||||
$scope.changeEmail = function(info, callback) {
|
||||
var details = {
|
||||
'email': $scope.changeEmailInfo.email
|
||||
};
|
||||
|
||||
$scope.changeEmailInfo.state = 'sending';
|
||||
var errorDisplay = ApiService.errorDisplay('Could not change email address', callback);
|
||||
|
||||
ApiService.changeUserDetails(details).then(function() {
|
||||
$scope.changeEmailInfo.state = 'sent';
|
||||
delete $scope.changeEmailInfo['email'];
|
||||
}, function(result) {
|
||||
$scope.changeEmailInfo.state = 'send-error';
|
||||
UIService.showFormError('#changeEmailForm', result);
|
||||
});
|
||||
callback(true);
|
||||
}, errorDisplay);
|
||||
};
|
||||
|
||||
$scope.showChangeAccount = function() {
|
||||
$scope.convertAccountInfo = {
|
||||
'user': $scope.context.viewuser
|
||||
};
|
||||
};
|
||||
|
||||
$scope.showBilling = function() {
|
||||
$scope.showBillingCounter++;
|
||||
};
|
||||
}
|
||||
})();
|
|
@ -313,7 +313,6 @@ angular.module('quay').factory('ApiService', ['Restangular', '$q', 'UtilService'
|
|||
}
|
||||
|
||||
message = UtilService.stringToHTML(message);
|
||||
|
||||
bootbox.dialog({
|
||||
"message": message,
|
||||
"title": defaultMessage || 'Request Failure',
|
||||
|
|
32
static/partials/billing.html
Normal file
32
static/partials/billing.html
Normal file
|
@ -0,0 +1,32 @@
|
|||
<div class="billing-page page-content">
|
||||
<div class="resource-view" resource="entityResource" error-message="'Could not load entity'">
|
||||
<div class="cor-title" ng-if="organization">
|
||||
<span class="cor-title-link">
|
||||
<a class="back-link" href="/organization/{{ organization.name }}?tab=settings">
|
||||
<span class="avatar" size="24" data="organization.avatar"></span>
|
||||
{{ organization.name }}
|
||||
</a>
|
||||
</span>
|
||||
<span class="cor-title-content">
|
||||
Account Plan
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="cor-title" ng-if="viewuser">
|
||||
<span class="cor-title-link">
|
||||
<a class="back-link" href="/user/{{ viewuser.username }}?tab=settings">
|
||||
<span class="avatar" size="24" data="viewuser.avatar"></span>
|
||||
{{ viewuser.username }}
|
||||
</a>
|
||||
</span>
|
||||
<span class="cor-title-content">
|
||||
Account Plan
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="co-main-content-panel" style="min-height: 500px;">
|
||||
<div class="plan-manager" organization="organization.name" has-subscription="hasSubscription" ng-if="organization"></div>
|
||||
<div class="plan-manager" user="viewuser" has-subscription="hasSubscription" ng-if="!organization"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
32
static/partials/invoices.html
Normal file
32
static/partials/invoices.html
Normal file
|
@ -0,0 +1,32 @@
|
|||
<div class="billing-page page-content">
|
||||
<div class="resource-view" resource="entityResource" error-message="'Could not load entity'">
|
||||
<div class="cor-title" ng-if="organization">
|
||||
<span class="cor-title-link">
|
||||
<a class="back-link" href="/organization/{{ organization.name }}?tab=settings">
|
||||
<span class="avatar" size="24" data="organization.avatar"></span>
|
||||
{{ organization.name }}
|
||||
</a>
|
||||
</span>
|
||||
<span class="cor-title-content">
|
||||
Billing Invoices
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="cor-title" ng-if="viewuser">
|
||||
<span class="cor-title-link">
|
||||
<a class="back-link" href="/user/{{ viewuser.username }}?tab=settings">
|
||||
<span class="avatar" size="24" data="viewuser.avatar"></span>
|
||||
{{ viewuser.username }}
|
||||
</a>
|
||||
</span>
|
||||
<span class="cor-title-content">
|
||||
Billing Invoices
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="co-main-content-panel" style="min-height: 500px;">
|
||||
<div class="billing-invoices" user="viewuser" makevisible="true" ng-if="!organization"></div>
|
||||
<div class="billing-invoices" organization="organization" makevisible="true" ng-if="organization"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -35,14 +35,6 @@
|
|||
<span class="cor-tab" tab-title="Default Permissions" tab-target="#default" ng-show="isAdmin">
|
||||
<i class="fa ci-stamp"></i>
|
||||
</span>
|
||||
<span class="cor-tab" tab-title="Billing" tab-target="#usage"
|
||||
quay-show="isAdmin && Features.BILLING">
|
||||
<i class="fa fa-credit-card"></i>
|
||||
</span>
|
||||
<span class="cor-tab" tab-title="Billing Invoices" tab-target="#invoices"
|
||||
tab-init="showInvoices()" quay-show="isAdmin && Features.BILLING">
|
||||
<i class="fa ci-invoice"></i>
|
||||
</span>
|
||||
<span class="cor-tab" tab-title="Usage Logs" tab-target="#logs"
|
||||
tab-init="showLogs()" ng-show="isAdmin">
|
||||
<i class="fa fa-bar-chart"></i>
|
||||
|
@ -52,7 +44,7 @@
|
|||
<i class="fa ci-application"></i>
|
||||
</span>
|
||||
<span class="cor-tab" tab-title="Organization Settings" tab-target="#settings"
|
||||
ng-show="isAdmin">
|
||||
ng-show="isAdmin" tab-init="showBilling()">
|
||||
<i class="fa fa-gears"></i>
|
||||
</span>
|
||||
</div> <!-- /cor-tabs -->
|
||||
|
@ -95,45 +87,60 @@
|
|||
makevisible="showApplicationsCounter"></div>
|
||||
</div>
|
||||
|
||||
<!-- Plan and Usage -->
|
||||
<div id="usage" class="tab-pane" quay-require="['BILLING']">
|
||||
<h3>Plan Usage and Billing</h3>
|
||||
<div ng-if="isAdmin">
|
||||
<div class="plan-manager" organization="organization.name"
|
||||
has-subscription="hasSubscription"></div>
|
||||
<hr ng-show="hasSubscription">
|
||||
<div class="billing-options" organization="organization" ng-show="hasSubscription"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Billing Invoices -->
|
||||
<div id="invoices" class="tab-pane" quay-require="['BILLING']">
|
||||
<h3>Billing Invoices</h3>
|
||||
<div class="billing-invoices" organization="organization"
|
||||
makevisible="showInvoicesCounter"></div>
|
||||
</div>
|
||||
|
||||
<!-- Settings -->
|
||||
<div id="settings" class="tab-pane">
|
||||
<div ng-if="isAdmin">
|
||||
<h3>Organization Settings</h3>
|
||||
<!-- Org Settings -->
|
||||
<div class="settings-section">
|
||||
<h3>Organization Settings</h3>
|
||||
<table class="co-list-table">
|
||||
<tr>
|
||||
<td>Namespace:</td>
|
||||
<td>
|
||||
{{ organization.name }}
|
||||
<div class="help-text">Organization names cannot currently be changed. Please <a href="/contact">contact support</a> to migrate accounts.</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Avatar:</td>
|
||||
<td>
|
||||
<span class="avatar" size="48" data="organization.avatar"></span>
|
||||
<div class="help-text" ng-if="Config.AVATAR_KIND == 'local'">Avatar is generated based off the organization's name.</div>
|
||||
<div class="help-text" ng-if="Config.AVATAR_KIND == 'gravatar'">Avatar is served by <a href="http://gravatar.com" rel="nofollow" target="_blank">Gravatar</a> based on the {{ organization.email }} e-mail address.</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr quay-show="Features.MAILING">
|
||||
<td>Email Address:</td>
|
||||
<td>
|
||||
<a class="co-modify-link" ng-click="showChangeEmail()">{{ organization.email }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="panel" ng-show="!orgScope.changingOrganization">
|
||||
<div class="panel-title">Organization's e-mail address</div>
|
||||
<div class="panel-content" style="padding-left: 20px; margin-top: 10px;">
|
||||
<form class="form-change" id="changeEmailForm" name="changeEmailForm" ng-submit="changeEmail()">
|
||||
<input type="email" class="form-control" style="max-width: 500px;"
|
||||
ng-model="orgScope.organizationEmail" required>
|
||||
<button class="btn btn-primary" type="submit"
|
||||
ng-disabled="changeEmailForm.$invalid || orgScope.organizationEmail == organization.email">
|
||||
Save
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<!-- Billing Information -->
|
||||
<div class="settings-section">
|
||||
<h3>Billing Information</h3>
|
||||
<div class="billing-management-panel" organization="organization" is-enabled="showBillingCounter"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div> <!-- /cor-tab-content -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Change email dialog -->
|
||||
<div class="cor-confirm-dialog"
|
||||
dialog-context="changeEmailInfo"
|
||||
dialog-action="changeEmail(info, callback)"
|
||||
dialog-title="Change E-mail Address"
|
||||
dialog-action-title="Change Email"
|
||||
dialog-form="context.emailform">
|
||||
<form name="context.emailform" class="co-single-field-dialog">
|
||||
Please enter a new email address.
|
||||
<input type="email" class="form-control" placeholder="Your new e-mail address"
|
||||
ng-model="changeEmailInfo.email" required>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -28,24 +28,16 @@
|
|||
<span class="cor-tab" tab-title="Robot Accounts" tab-init="showRobots()" tab-target="#robots">
|
||||
<i class="fa ci-robot"></i>
|
||||
</span>
|
||||
<span class="cor-tab" tab-title="User Settings" tab-target="#settings">
|
||||
<i class="fa fa-gears"></i>
|
||||
</span>
|
||||
<span class="cor-tab" tab-title="Billing" tab-target="#usage"
|
||||
quay-show="Features.BILLING">
|
||||
<i class="fa fa-credit-card"></i>
|
||||
</span>
|
||||
<span class="cor-tab" tab-title="Billing Invoices" tab-target="#invoices"
|
||||
tab-init="showInvoices()" quay-show="Features.BILLING">
|
||||
<i class="fa ci-invoice"></i>
|
||||
</span>
|
||||
<span class="cor-tab" tab-title="External Logins" tab-target="#external"
|
||||
quay-show="!hasSingleSignin">
|
||||
<span class="cor-tab" tab-title="External Logins And Applications" tab-target="#external"
|
||||
tab-init="showApplications()">
|
||||
<i class="fa fa-external-link-square"></i>
|
||||
</span>
|
||||
<span class="cor-tab" tab-title="Authorized Applications" tab-target="#applications"
|
||||
tab-init="showApplications()">
|
||||
<i class="fa ci-application"></i>
|
||||
<span class="cor-tab" tab-title="Usage Logs" tab-target="#logs" tab-init="showLogs()"
|
||||
quay-show="Features.USER_LOG_ACCESS">
|
||||
<i class="fa fa-bar-chart"></i>
|
||||
</span>
|
||||
<span class="cor-tab" tab-title="User Settings" tab-target="#settings" tab-init="showBilling()">
|
||||
<i class="fa fa-gears"></i>
|
||||
</span>
|
||||
</div> <!-- /cor-tabs -->
|
||||
|
||||
|
@ -60,149 +52,129 @@
|
|||
<div class="robots-manager" user="viewuser" is-enabled="showRobotsCounter"></div>
|
||||
</div>
|
||||
|
||||
<!-- External Logins -->
|
||||
<!-- Usage Logs -->
|
||||
<div id="logs" class="tab-pane">
|
||||
<div class="logs-view" user="viewuser" makevisible="showLogsCounter"></div>
|
||||
</div>
|
||||
|
||||
<!-- External Logins And Applications -->
|
||||
<div id="external" class="tab-pane" quay-show="!hasSingleSignin">
|
||||
<div class="external-logins-manager" user="viewuser"></div>
|
||||
</div>
|
||||
|
||||
<!-- Applications -->
|
||||
<div id="applications" class="tab-pane">
|
||||
<div class="external-logins-manager" user="viewuser"
|
||||
quay-show="!hasSingleSignin"></div>
|
||||
<div style="margin: 50px" quay-show="!hasSingleSignin">
|
||||
</div>
|
||||
<div class="authorized-apps-manager" user="viewuser" is-enabled="showAppsCounter"></div>
|
||||
</div>
|
||||
|
||||
<!-- Plan and Usage -->
|
||||
<div id="usage" class="tab-pane" quay-require="['BILLING']">
|
||||
<h3>Plan Usage and Billing</h3>
|
||||
<div class="plan-manager" user="viewuser" has-subscription="hasSubscription"></div>
|
||||
<hr ng-show="hasSubscription">
|
||||
<div class="billing-options" user="viewuser" ng-show="hasSubscription"></div>
|
||||
</div>
|
||||
|
||||
<!-- Billing Invoices -->
|
||||
<div id="invoices" class="tab-pane" quay-require="['BILLING']">
|
||||
<h3>Billing Invoices</h3>
|
||||
<div class="billing-invoices" user="viewuser"
|
||||
makevisible="showInvoicesCounter"></div>
|
||||
</div>
|
||||
|
||||
<!-- Settings -->
|
||||
<div id="settings" class="tab-pane">
|
||||
<h3>User Settings</h3>
|
||||
|
||||
<!-- E-mail address -->
|
||||
<div class="co-panel" quay-show="Features.MAILING">
|
||||
<div class="co-panel-heading"><i class="fa fa-envelope-o"></i> E-mail Address</div>
|
||||
<div class="panel-body" style="padding-top: 5px;">
|
||||
|
||||
<div class="co-alert co-alert-success" ng-show="changeEmailInfo.state == 'sent'">
|
||||
An e-mail has been sent to {{ sentEmail }} to verify the change.
|
||||
</div>
|
||||
|
||||
<div class="cor-loader" ng-show="changeEmailInfo.state == 'sending'"></div>
|
||||
<div ng-show="changeEmailInfo.state != 'sending'">
|
||||
<form class="form-change user-settings-form"
|
||||
id="changeEmailForm" name="changeEmailForm"
|
||||
ng-submit="changeEmail(); changeEmailForm.$setPristine()"
|
||||
ng-show="!awaitingConfirmation && !registering">
|
||||
|
||||
<div class="row">
|
||||
<table class="col-md-6">
|
||||
<tr>
|
||||
<td>Current E-mail Address:</td>
|
||||
<td>{{ context.viewuser.email }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>New E-mail Address:</td>
|
||||
<td>
|
||||
<input type="email" class="form-control"
|
||||
placeholder="Your new e-mail address"
|
||||
ng-model="changeEmailInfo.email" required>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary"
|
||||
ng-disabled="changeEmailForm.$invalid || changeEmail.email == context.viewuser.email"
|
||||
type="submit">
|
||||
Change E-mail Address
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<!-- Encrypted Password -->
|
||||
<div class="settings-section">
|
||||
<h3>Docker CLI Password</h3>
|
||||
<div ng-if="!Features.REQUIRE_ENCRYPTED_BASIC_AUTH">
|
||||
The Docker CLI stores passwords entered on the command line in <strong>plaintext</strong>. It is therefore highly recommended to generate an an encrypted version of your password to use for <code>docker login</code>.
|
||||
</div>
|
||||
</div> <!-- /E-mail -->
|
||||
|
||||
<!-- Password -->
|
||||
<div class="co-panel" style="margin-bottom: 0px">
|
||||
<div class="co-panel-heading"><i class="fa fa-lock"></i> Password</div>
|
||||
<div class="panel-body" style="padding-top: 5px;">
|
||||
<div class="cor-loader" ng-show="changePasswordInfo.state == 'changing'"></div>
|
||||
|
||||
<!-- Encrypted Password -->
|
||||
<div class="row" ng-show="changePasswordInfo.state !='changing'">
|
||||
<div class="panel">
|
||||
<div class="panel-title">Generate Encrypted Password</div>
|
||||
|
||||
<div class="panel-body">
|
||||
<div class="co-alert co-alert-info" ng-if="!Features.REQUIRE_ENCRYPTED_BASIC_AUTH">
|
||||
Due to Docker storing passwords entered on the command line in <strong>plaintext</strong>, it is highly recommended to use the button below to generate an an encrypted version of your password.
|
||||
</div>
|
||||
|
||||
<div class="co-alert co-alert-warning" ng-if="Features.REQUIRE_ENCRYPTED_BASIC_AUTH">
|
||||
This installation is set to <strong>require</strong> encrypted passwords when
|
||||
using the Docker command line interface. To generate an encrypted password, click the button below.
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary" ng-click="generateClientToken()">
|
||||
<i class="fa fa-key" style="margin-right: 6px;"></i>Generate Encrypted Password
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Change Password -->
|
||||
<div class="row" quay-show="Config.AUTHENTICATION_TYPE == 'Database' && changePasswordInfo.state !='changing'">
|
||||
<div class="panel">
|
||||
<div class="panel-title">Change Password</div>
|
||||
|
||||
<span class="help-block" ng-show="changePasswordInfo.state == 'changed'">
|
||||
Password changed successfully
|
||||
</span>
|
||||
|
||||
<div class="panel-body">
|
||||
<div class="co-alert co-alert-warning">Note: Changing your password will also invalidate any generated encrypted passwords.</div>
|
||||
|
||||
|
||||
<form class="form-change col-md-6" id="changePasswordForm" name="changePasswordForm" ng-submit="changePassword(); changePasswordForm.$setPristine()"
|
||||
ng-show="!awaitingConfirmation && !registering">
|
||||
<input type="password" class="form-control" placeholder="Your new password" ng-model="changePasswordInfo.password" required
|
||||
ng-pattern="/^.{8,}$/">
|
||||
<input type="password" class="form-control" placeholder="Verify your new password" ng-model="changePasswordInfo.repeatPassword"
|
||||
match="changePasswordInfo.password" required ng-pattern="/^.{8,}$/">
|
||||
<button class="btn btn-danger" ng-disabled="changePasswordForm.$invalid" type="submit"
|
||||
analytics-on analytics-event="change_pass">Change Password</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div ng-if="Features.REQUIRE_ENCRYPTED_BASIC_AUTH">
|
||||
This installation is set to <strong>require</strong> encrypted passwords when
|
||||
using the Docker command line interface.
|
||||
</div>
|
||||
</div> <!-- /Password -->
|
||||
|
||||
<!-- Convert -->
|
||||
<div class="co-panel" quay-show="Config.AUTHENTICATION_TYPE == 'Database'">
|
||||
<div class="co-panel-heading"><i class="fa fa-group"></i> Convert to organization</div>
|
||||
<div class="panel-body" style="padding-top: 5px;">
|
||||
<div class="convert-user-to-org" user="viewuser"></div>
|
||||
</div>
|
||||
<table class="co-list-table" style="margin-top: 10px;">
|
||||
<tr>
|
||||
<td>CLI Password:</td>
|
||||
<td>
|
||||
<a ng-click="generateClientToken()">Generate Encrypted Password</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div> <!-- /Convert -->
|
||||
|
||||
</div> <!-- /cor-tab-content -->
|
||||
<!-- User Settings -->
|
||||
<div class="settings-section">
|
||||
<h3>User Settings</h3>
|
||||
<table class="co-list-table">
|
||||
<tr>
|
||||
<td>Username:</td>
|
||||
<td>
|
||||
{{ context.viewuser.username }}
|
||||
<div class="help-text">Usernames cannot currently be changed. Please <a href="/contact">contact support</a> to migrate accounts.</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Avatar:</td>
|
||||
<td>
|
||||
<span class="avatar" size="48" data="context.viewuser.avatar"></span>
|
||||
<div class="help-text" ng-if="Config.AVATAR_KIND == 'local'">Avatar is generated based off of your username.</div>
|
||||
<div class="help-text" ng-if="Config.AVATAR_KIND == 'gravatar'">Avatar is served by <a href="http://gravatar.com" rel="nofollow" target="_blank">Gravatar</a> based on the {{ context.viewuser.email }} e-mail address.</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr quay-show="Features.MAILING">
|
||||
<td>Email Address:</td>
|
||||
<td>
|
||||
<a class="co-modify-link" ng-click="showChangeEmail()">{{ context.viewuser.email }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr quay-show="Config.AUTHENTICATION_TYPE == 'Database'">
|
||||
<td>Password:</td>
|
||||
<td>
|
||||
<a class="co-modify-link" ng-click="showChangePassword()">Change password</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr quay-show="Config.AUTHENTICATION_TYPE == 'Database'">
|
||||
<td>Account Type:</td>
|
||||
<td>
|
||||
<a class="co-modify-link" ng-click="showChangeAccount()">Individual account</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Billing Information -->
|
||||
<div class="settings-section">
|
||||
<h3>Billing Information</h3>
|
||||
<div class="billing-management-panel" user="context.viewuser" is-enabled="showBillingCounter"></div>
|
||||
</div>
|
||||
|
||||
</div> <!-- /cor-tab-content -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Change email dialog -->
|
||||
<div class="cor-confirm-dialog"
|
||||
dialog-context="changeEmailInfo"
|
||||
dialog-action="changeEmail(info, callback)"
|
||||
dialog-title="Change E-mail Address"
|
||||
dialog-action-title="Change Email"
|
||||
dialog-form="context.emailform">
|
||||
<form name="context.emailform" class="co-single-field-dialog">
|
||||
Please enter a new email address. A verification email will be sent before being applied.
|
||||
<input type="email" class="form-control" placeholder="Your new e-mail address"
|
||||
ng-model="changeEmailInfo.email" required>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Change password dialog -->
|
||||
<div class="cor-confirm-dialog"
|
||||
dialog-context="changePasswordInfo"
|
||||
dialog-action="changePassword(info, callback)"
|
||||
dialog-title="Change Password"
|
||||
dialog-action-title="Change Password"
|
||||
dialog-form="context.passwordform">
|
||||
<form name="context.passwordform" class="co-single-field-dialog">
|
||||
Enter a new password. Passwords must be at least 8 characters in length.
|
||||
<input type="password" class="form-control" placeholder="Your new password" ng-model="changePasswordInfo.password" required
|
||||
ng-pattern="/^.{8,}$/">
|
||||
<input type="password" class="form-control" placeholder="Verify your new password" ng-model="changePasswordInfo.repeatPassword"
|
||||
match="changePasswordInfo.password" required ng-pattern="/^.{8,}$/">
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Convert account dialog -->
|
||||
<div class="convert-user-to-org" info="convertAccountInfo"></div>
|
||||
|
||||
<!-- Modal message dialog -->
|
||||
<div class="modal fade" id="clientTokenModal">
|
||||
<div class="co-dialog modal fade" id="clientTokenModal">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
|
|
Reference in a new issue