mirror of
https://github.com/GSA/notifications-admin.git
synced 2026-08-11 09:28:27 -04:00
Merged in main
This commit is contained in:
@@ -2,22 +2,34 @@ from collections import OrderedDict
|
||||
from datetime import datetime
|
||||
from functools import partial
|
||||
|
||||
from flask import current_app, flash, redirect, render_template, request, url_for
|
||||
from flask import (
|
||||
current_app,
|
||||
flash,
|
||||
redirect,
|
||||
render_template,
|
||||
request,
|
||||
session,
|
||||
url_for,
|
||||
)
|
||||
from flask_login import current_user
|
||||
|
||||
from app import current_organization, org_invite_api_client, organizations_client
|
||||
from app.enums import OrganizationType
|
||||
from app.formatters import email_safe
|
||||
from app.main import main
|
||||
from app.main.forms import (
|
||||
AdminBillingDetailsForm,
|
||||
AdminNewOrganizationForm,
|
||||
AdminNotesForm,
|
||||
AdminOrganizationDomainsForm,
|
||||
CreateServiceForm,
|
||||
InviteOrgUserForm,
|
||||
OrganizationOrganizationTypeForm,
|
||||
RenameOrganizationForm,
|
||||
SearchByNameForm,
|
||||
SearchUsersForm,
|
||||
)
|
||||
from app.main.views.add_service import _create_service
|
||||
from app.main.views.dashboard import (
|
||||
get_tuples_of_financial_years,
|
||||
requested_and_current_financial_year,
|
||||
@@ -113,17 +125,67 @@ def get_services_dashboard_data(organization, year):
|
||||
return services
|
||||
|
||||
|
||||
@main.route("/organizations/<uuid:org_id>", methods=["GET"])
|
||||
@main.route("/organizations/<uuid:org_id>", methods=["GET", "POST"])
|
||||
@user_has_permissions()
|
||||
def organization_dashboard(org_id):
|
||||
if not current_app.config.get("ORGANIZATION_DASHBOARD_ENABLED", False):
|
||||
return redirect(url_for(".organization_usage", org_id=org_id))
|
||||
|
||||
year = requested_and_current_financial_year(request)[0]
|
||||
action = request.args.get("action")
|
||||
|
||||
create_service_form = None
|
||||
invite_user_form = None
|
||||
|
||||
if action == "create-service" or request.form.get("form_name") == "create_service":
|
||||
create_service_form = CreateServiceForm(
|
||||
organization_type=current_user.default_organization_type
|
||||
or OrganizationType.FEDERAL
|
||||
)
|
||||
|
||||
if request.method == "POST" and create_service_form.validate_on_submit():
|
||||
service_name = create_service_form.name.data
|
||||
service_id, error = _create_service(
|
||||
service_name,
|
||||
create_service_form.organization_type.data,
|
||||
email_safe(service_name),
|
||||
create_service_form,
|
||||
)
|
||||
if not error:
|
||||
current_organization.associate_service(service_id)
|
||||
current_app.logger.info(
|
||||
f"Service {service_id} created and associated with org {org_id}"
|
||||
)
|
||||
flash(f"Service '{service_name}' has been created", "default_with_tick")
|
||||
session["new_service_id"] = service_id
|
||||
return redirect(url_for(".organization_dashboard", org_id=org_id))
|
||||
else:
|
||||
current_app.logger.error(f"Error creating service: {error}")
|
||||
flash("Error creating service", "error")
|
||||
|
||||
if action == "invite-user" or request.form.get("form_name") == "invite_user":
|
||||
invite_user_form = InviteOrgUserForm(
|
||||
inviter_email_address=current_user.email_address
|
||||
)
|
||||
|
||||
if request.method == "POST" and invite_user_form.validate_on_submit():
|
||||
try:
|
||||
invited_org_user = InvitedOrgUser.create(
|
||||
current_user.id, org_id, invite_user_form.email_address.data
|
||||
)
|
||||
flash(
|
||||
f"Invite sent to {invited_org_user.email_address}",
|
||||
"default_with_tick",
|
||||
)
|
||||
return redirect(url_for(".organization_dashboard", org_id=org_id))
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error inviting user: {e}")
|
||||
flash("Error sending invitation", "error")
|
||||
|
||||
message_allowance = get_organization_message_allowance(org_id)
|
||||
|
||||
services_with_usage = get_services_dashboard_data(current_organization, year)
|
||||
new_service_id = session.pop("new_service_id", None)
|
||||
|
||||
return render_template(
|
||||
"views/organizations/organization/index.html",
|
||||
@@ -133,6 +195,11 @@ def organization_dashboard(org_id):
|
||||
trial_services=len(current_organization.trial_services),
|
||||
suspended_services=len(current_organization.suspended_services),
|
||||
total_services=len(current_organization.services),
|
||||
create_service_form=create_service_form,
|
||||
invite_user_form=invite_user_form,
|
||||
show_create_service=create_service_form is not None,
|
||||
show_invite_user=invite_user_form is not None,
|
||||
new_service_id=new_service_id,
|
||||
**message_allowance,
|
||||
)
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import os
|
||||
import secrets
|
||||
import time
|
||||
import uuid
|
||||
from urllib.parse import unquote
|
||||
from urllib.parse import unquote, urlparse
|
||||
|
||||
import jwt
|
||||
import requests
|
||||
@@ -24,7 +24,7 @@ from app.main.views.index import error
|
||||
from app.main.views.verify import activate_user
|
||||
from app.models.user import User
|
||||
from app.utils import hide_from_search_engines
|
||||
from app.utils.login import get_id_token, is_safe_redirect_url
|
||||
from app.utils.login import get_id_token
|
||||
|
||||
# from app.utils.time import is_less_than_days_ago
|
||||
from app.utils.user import is_gov_user
|
||||
@@ -179,8 +179,12 @@ def _handle_e2e_tests(redirect_url): # pragma: no cover
|
||||
activate_user(user["id"])
|
||||
|
||||
# Check if the redirect URL is present and safe before proceeding further
|
||||
if redirect_url and is_safe_redirect_url(redirect_url):
|
||||
return redirect(redirect_url)
|
||||
# Defensive: sanitize backslashes, check for absolute URLs
|
||||
if redirect_url:
|
||||
cleaned_redirect_url = redirect_url.replace("\\", "")
|
||||
parts = urlparse(cleaned_redirect_url)
|
||||
if not parts.netloc and not parts.scheme:
|
||||
return redirect(cleaned_redirect_url)
|
||||
|
||||
return redirect(
|
||||
url_for(
|
||||
@@ -215,8 +219,12 @@ def sign_in(): # pragma: no cover
|
||||
return email_verify_template
|
||||
|
||||
if current_user and current_user.is_authenticated:
|
||||
if redirect_url and is_safe_redirect_url(redirect_url):
|
||||
return redirect(redirect_url)
|
||||
if redirect_url:
|
||||
# Defensive: sanitize backslashes, check for absolute URLs
|
||||
cleaned_redirect_url = redirect_url.replace("\\", "")
|
||||
parts = urlparse(cleaned_redirect_url)
|
||||
if not parts.netloc and not parts.scheme:
|
||||
return redirect(cleaned_redirect_url)
|
||||
return redirect(url_for("main.show_accounts_or_dashboard"))
|
||||
|
||||
ttl = 24 * 60 * 60
|
||||
|
||||
@@ -3,31 +3,35 @@
|
||||
|
||||
{% macro banner(body, type=None, with_tick=False, delete_button=None, subhead=None, context=None, action=None, id=None, thing=None) %}
|
||||
<div
|
||||
class='banner{% if type %}-{{ type }}{% endif %}{% if with_tick %}-with-tick{% endif %}'
|
||||
class="usa-alert {% if type == 'dangerous' %}usa-alert--error{% else %}usa-alert--success{% endif %} banner{% if type %}-{{ type }}{% endif %}{% if with_tick %}-with-tick{% endif %}"
|
||||
{% if id %}
|
||||
id={{ id }}
|
||||
{% endif %}
|
||||
>
|
||||
{% if subhead -%}
|
||||
<h1 class="banner-title font-body-lg">{{ subhead }}</h1>
|
||||
{%- endif -%}
|
||||
{{ body }}
|
||||
{% if context %}
|
||||
<p class="usa-body">
|
||||
{{ context }}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if delete_button %}
|
||||
{% call form_wrapper(action=action) %}
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||
{{ usaButton({
|
||||
"text": "" if thing else delete_button,
|
||||
"html": delete_button + "<span class=\"usa-sr-only\"> ‘" + thing + "’</span>" if thing else "",
|
||||
"name": "delete",
|
||||
"classes": "margin-top-2 usa-button--secondary",
|
||||
}) }}
|
||||
{% endcall %}
|
||||
{% endif %}
|
||||
<div class="usa-alert__body">
|
||||
{% if subhead -%}
|
||||
<h3 class="usa-alert__heading">{{ subhead }}</h3>
|
||||
{%- endif -%}
|
||||
<p class="usa-alert__text">
|
||||
{{ body }}
|
||||
</p>
|
||||
{% if context %}
|
||||
<p class="usa-alert__text">
|
||||
{{ context }}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if delete_button %}
|
||||
{% call form_wrapper(action=action) %}
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||
{{ usaButton({
|
||||
"text": "" if thing else delete_button,
|
||||
"html": delete_button + "<span class=\"usa-sr-only\"> '" + thing + "'</span>" if thing else "",
|
||||
"name": "delete",
|
||||
"classes": "margin-top-2 usa-button--secondary",
|
||||
}) }}
|
||||
{% endcall %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
@@ -36,10 +36,10 @@
|
||||
<div class="text-base-dark text-uppercase font-sans-3xs text-ls-1 margin-bottom-05">Total Services</div>
|
||||
<div class="font-sans-xl text-primary-darker line-height-sans-1">{{ total_services }}</div>
|
||||
</div>
|
||||
<div class="text-base-dark font-body-2xs line-height-sans-3">
|
||||
<span class="text-success-dark text-bold">{{ live_services }}</span> Live
|
||||
<span class="margin-left-1 text-warning-dark text-bold">{{ trial_services }}</span> Trial
|
||||
<span class="margin-left-1 text-base-dark text-bold">{{ suspended_services }}</span> Suspended
|
||||
<div class="text-ink font-body-2xs line-height-sans-3">
|
||||
<span class="text-bold">{{ live_services }}</span> Live
|
||||
<span class="margin-left-1 text-bold">{{ trial_services }}</span> Trial
|
||||
<span class="margin-left-1 text-bold">{{ suspended_services }}</span> Suspended
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -58,23 +58,77 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details class="usa-details">
|
||||
<summary class="usa-details__summary font-heading-lg ">What is a service?</summary>
|
||||
<div class="usa-details__content">
|
||||
<p class="usa-body">
|
||||
When you join Notify, you're added to a service. This is your organization's workspace for sending text messages and emails. Within your service, you can:
|
||||
</p>
|
||||
<ol class="usa-list">
|
||||
<li>Create and edit message templates</li>
|
||||
<li>Send messages to recipients</li>
|
||||
<li>View message status and history</li>
|
||||
<li>Manage team members and permissions</li>
|
||||
<li>Track usage and delivery statistics</li>
|
||||
<li>If you work for multiple organizations, you may belong to multiple services and can switch between them.</li>
|
||||
</ol>
|
||||
</div>
|
||||
</details>
|
||||
<div class="display-flex flex-gap-1 margin-bottom-3">
|
||||
<a {% if not show_create_service %}href="{{ url_for('.organization_dashboard', org_id=current_org.id, action='create-service') }}"{% endif %}
|
||||
class="usa-button {% if show_create_service %}usa-button--base button-not-clickable{% else %}usa-button--outline{% endif %}">
|
||||
Create new service
|
||||
</a>
|
||||
<a {% if not show_invite_user %}href="{{ url_for('.organization_dashboard', org_id=current_org.id, action='invite-user') }}"{% endif %}
|
||||
class="usa-button {% if show_invite_user %}usa-button--base button-not-clickable{% else %}usa-button--outline{% endif %}">
|
||||
Add org admin
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% if show_create_service and create_service_form %}
|
||||
<div id="create-service-form" class="bg-base-lightest padding-3 radius-md margin-bottom-3">
|
||||
<h3 class="margin-top-0">Create a new service</h3>
|
||||
<form method="post" action="{{ url_for('.organization_dashboard', org_id=current_org.id, action='create-service') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ create_service_form.csrf_token._value() }}"/>
|
||||
<input type="hidden" name="form_name" value="create_service"/>
|
||||
|
||||
{{ create_service_form.name(param_extensions={"hint": {"text": "You can change this later"}}) }}
|
||||
|
||||
<div class="display-flex flex-gap-1 margin-top-3">
|
||||
<button type="submit" class="usa-button">Create service</button>
|
||||
<a href="{{ url_for('.organization_dashboard', org_id=current_org.id) }}" class="usa-button usa-button--outline">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if show_invite_user and invite_user_form %}
|
||||
<div id="invite-user-form" class="bg-base-lightest padding-3 radius-md margin-bottom-3">
|
||||
<h3 class="margin-top-0">Invite a team member</h3>
|
||||
<p class="margin-top-0">{{ current_org.name }} team members can see usage and team members for each service, and invite other team members.</p>
|
||||
<form method="post" action="{{ url_for('.organization_dashboard', org_id=current_org.id, action='invite-user') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ invite_user_form.csrf_token._value() }}"/>
|
||||
<input type="hidden" name="form_name" value="invite_user"/>
|
||||
|
||||
{{ invite_user_form.email_address(param_extensions={"classes": ""}, error_message_with_html=True) }}
|
||||
|
||||
<div class="display-flex flex-gap-1 margin-top-3">
|
||||
<button type="submit" class="usa-button">Send invitation</button>
|
||||
<a href="{{ url_for('.organization_dashboard', org_id=current_org.id) }}" class="usa-button usa-button--outline">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="usa-accordion margin-bottom-3">
|
||||
<h3 class="usa-accordion__heading">
|
||||
<button
|
||||
type="button"
|
||||
class="usa-accordion__button"
|
||||
aria-expanded="false"
|
||||
aria-controls="what-is-service-content"
|
||||
>
|
||||
What is a service?
|
||||
</button>
|
||||
</h3>
|
||||
<div id="what-is-service-content" class="usa-accordion__content usa-prose" hidden>
|
||||
<p>
|
||||
When you join Notify, you're added to a service. This is your organization's workspace for sending text messages and emails. Within your service, you can:
|
||||
</p>
|
||||
<ol class="usa-list">
|
||||
<li>Create and edit message templates</li>
|
||||
<li>Send messages to recipients</li>
|
||||
<li>View message status and history</li>
|
||||
<li>Manage team members and permissions</li>
|
||||
<li>Track usage and delivery statistics</li>
|
||||
<li>If you work for multiple organizations, you may belong to multiple services and can switch between them.</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="margin-bottom-5">
|
||||
@@ -93,7 +147,8 @@
|
||||
<tbody>
|
||||
{% if services %}
|
||||
{% for service in services %}
|
||||
<tr>
|
||||
{% set is_new_service = new_service_id and service.id == new_service_id %}
|
||||
<tr id="service-{{ service.id }}" {% if is_new_service %}class="is-highlighted"{% endif %}>
|
||||
<td><a href="{{ url_for('main.service_dashboard', service_id=service.id) }}" class="usa-link">{{ service.name }}</a></td>
|
||||
<td>
|
||||
{% if not service.active %}
|
||||
@@ -120,3 +175,51 @@
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_javascripts %}
|
||||
<script nonce="{{ csp_nonce() }}">
|
||||
(function() {
|
||||
function scrollToElement(element, delay) {
|
||||
setTimeout(function() {
|
||||
element.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
inline: 'nearest'
|
||||
});
|
||||
}, delay || 0);
|
||||
}
|
||||
|
||||
function focusFirstInput(container, delay) {
|
||||
setTimeout(function() {
|
||||
var input = container.querySelector('input[type="text"], input[type="search"]');
|
||||
if (input) input.focus();
|
||||
}, delay || 0);
|
||||
}
|
||||
|
||||
{% if new_service_id %}
|
||||
var serviceRow = document.getElementById('service-{{ new_service_id }}');
|
||||
if (serviceRow) {
|
||||
scrollToElement(serviceRow, 300);
|
||||
|
||||
setTimeout(function() {
|
||||
serviceRow.classList.remove('is-highlighted');
|
||||
if (serviceRow.className === '') {
|
||||
serviceRow.removeAttribute('class');
|
||||
}
|
||||
}, 3300);
|
||||
}
|
||||
{% endif %}
|
||||
|
||||
requestAnimationFrame(function() {
|
||||
var form = document.getElementById('create-service-form') ||
|
||||
document.getElementById('invite-user-form');
|
||||
|
||||
if (form) {
|
||||
scrollToElement(form, 50);
|
||||
focusFirstInput(form, 150);
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{{ super() }}
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user