diff --git a/aleph/logic/api_keys.py b/aleph/logic/api_keys.py new file mode 100644 index 0000000000..98680ffe2d --- /dev/null +++ b/aleph/logic/api_keys.py @@ -0,0 +1,113 @@ +import datetime + +import structlog +from flask import render_template +from sqlalchemy import and_, or_, func + +from aleph.core import db +from aleph.model import Role +from aleph.model.common import make_token +from aleph.logic.mail import email_role +from aleph.logic.roles import update_role +from aleph.logic.util import ui_url + +# Number of days after which API keys expire +API_KEY_EXPIRATION_DAYS = 90 + +# Number of days before an API key expires +API_KEY_EXPIRES_SOON_DAYS = 7 + +log = structlog.get_logger(__name__) + + +def generate_user_api_key(role): + event = "regenerated" if role.has_api_key else "generated" + params = {"role": role, "event": event} + plain = render_template("email/api_key_generated.txt", **params) + html = render_template("email/api_key_generated.html", **params) + subject = f"API key {event}" + email_role(role, subject, html=html, plain=plain) + + now = datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0) + role.api_key = make_token() + role.api_key_expires_at = now + datetime.timedelta(days=API_KEY_EXPIRATION_DAYS) + role.api_key_expiration_notification_sent = None + + db.session.add(role) + db.session.commit() + update_role(role) + + return role.api_key + + +def send_api_key_expiration_notifications(): + _send_api_key_expiration_notification( + days=7, + subject="Your API key will expire in 7 days", + plain_template="email/api_key_expires_soon.txt", + html_template="email/api_key_expires_soon.html", + ) + + _send_api_key_expiration_notification( + days=0, + subject="Your API key has expired", + plain_template="email/api_key_expired.txt", + html_template="email/api_key_expired.html", + ) + + +def _send_api_key_expiration_notification( + days, + subject, + plain_template, + html_template, +): + now = datetime.date.today() + threshold = now + datetime.timedelta(days=days) + + query = Role.all_users() + query = query.yield_per(1000) + query = query.where( + and_( + and_( + Role.api_key != None, # noqa: E711 + func.date(Role.api_key_expires_at) <= threshold, + ), + or_( + Role.api_key_expiration_notification_sent == None, # noqa: E711 + Role.api_key_expiration_notification_sent > days, + ), + ) + ) + + for role in query: + expires_at = role.api_key_expires_at + params = { + "role": role, + "expires_at": expires_at, + "settings_url": ui_url("settings"), + } + plain = render_template(plain_template, **params) + html = render_template(html_template, **params) + log.info(f"Sending API key expiration notification: {role} at {expires_at}") + email_role(role, subject, html=html, plain=plain) + + query.update({Role.api_key_expiration_notification_sent: days}) + db.session.commit() + + +def reset_api_key_expiration(): + now = datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0) + expires_at = now + datetime.timedelta(days=API_KEY_EXPIRATION_DAYS) + + query = Role.all_users() + query = query.yield_per(500) + query = query.where( + and_( + Role.api_key != None, # noqa: E711 + Role.api_key_expires_at == None, # noqa: E711 + ) + ) + + query.update({Role.api_key_expires_at: expires_at}) + db.session.commit() diff --git a/aleph/manage.py b/aleph/manage.py index d508e05a27..155c1d1e5d 100644 --- a/aleph/manage.py +++ b/aleph/manage.py @@ -20,6 +20,7 @@ from aleph.queues import get_status, cancel_queue from aleph.queues import get_active_dataset_status from aleph.index.admin import delete_index +from aleph.logic.api_keys import reset_api_key_expiration as _reset_api_key_expiration from aleph.index.entities import iter_proxies from aleph.index.util import AlephOperationalException from aleph.logic.collections import create_collection, update_collection @@ -566,3 +567,9 @@ def evilshit(): delete_index() destroy_db() upgrade() + + +@cli.command() +def reset_api_key_expiration(): + """Reset the expiration date of all legacy, non-expiring API keys.""" + _reset_api_key_expiration() diff --git a/aleph/migrate/versions/131674bde902_add_primary_key_constraint_to_role_membership.py b/aleph/migrate/versions/131674bde902_add_primary_key_constraint_to_role_membership.py index f922d7b6e5..a94502aa37 100644 --- a/aleph/migrate/versions/131674bde902_add_primary_key_constraint_to_role_membership.py +++ b/aleph/migrate/versions/131674bde902_add_primary_key_constraint_to_role_membership.py @@ -1,14 +1,14 @@ """add primary key constraint to role_membership table Revision ID: 131674bde902 -Revises: c52a1f469ac7 +Revises: 8adf50aadcb0 Create Date: 2024-07-17 14:37:25.269913 """ # revision identifiers, used by Alembic. revision = "131674bde902" -down_revision = "c52a1f469ac7" +down_revision = "8adf50aadcb0" from alembic import op import sqlalchemy as sa diff --git a/aleph/migrate/versions/d46fc882ec6b_api_key_expiration.py b/aleph/migrate/versions/d46fc882ec6b_api_key_expiration.py new file mode 100644 index 0000000000..e2d6c777fc --- /dev/null +++ b/aleph/migrate/versions/d46fc882ec6b_api_key_expiration.py @@ -0,0 +1,30 @@ +"""API key expiration + +Revision ID: d46fc882ec6b +Revises: 131674bde902 +Create Date: 2024-05-02 11:43:50.993948 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = "d46fc882ec6b" +down_revision = "131674bde902" + + +def upgrade(): + op.add_column("role", sa.Column("api_key_expires_at", sa.DateTime())) + op.add_column( + "role", sa.Column("api_key_expiration_notification_sent", sa.Integer()) + ) + op.create_index( + index_name="ix_role_api_key_expires_at", + table_name="role", + columns=["api_key_expires_at"], + ) + + +def downgrade(): + op.drop_column("role", "api_key_expires_at") + op.drop_column("role", "api_key_expiration_notification_sent") diff --git a/aleph/model/role.py b/aleph/model/role.py index ae698ca1ce..eb60eb0464 100644 --- a/aleph/model/role.py +++ b/aleph/model/role.py @@ -1,5 +1,5 @@ import logging -from datetime import datetime +from datetime import datetime, timezone from normality import stringify from sqlalchemy import or_, not_, func from itsdangerous import URLSafeTimedSerializer @@ -7,7 +7,7 @@ from aleph.core import db from aleph.settings import SETTINGS -from aleph.model.common import SoftDeleteModel, IdModel, make_token, query_like +from aleph.model.common import SoftDeleteModel, IdModel, query_like from aleph.util import anonymize_email log = logging.getLogger(__name__) @@ -52,6 +52,8 @@ class Role(db.Model, IdModel, SoftDeleteModel): email = db.Column(db.Unicode, nullable=True) type = db.Column(db.Enum(*TYPES, name="role_type"), nullable=False) api_key = db.Column(db.Unicode, nullable=True) + api_key_expires_at = db.Column(db.DateTime, nullable=True) + api_key_expiration_notification_sent = db.Column(db.Integer, nullable=True) is_admin = db.Column(db.Boolean, nullable=False, default=False) is_muted = db.Column(db.Boolean, nullable=False, default=False) is_tester = db.Column(db.Boolean, nullable=False, default=False) @@ -68,6 +70,10 @@ class Role(db.Model, IdModel, SoftDeleteModel): def has_password(self): return self.password_digest is not None + @property + def has_api_key(self): + return self.api_key is not None + @property def is_public(self): return self.id in self.public_roles() @@ -160,11 +166,12 @@ def to_dict(self): "label": self.label, "email": self.email, "locale": self.locale, - "api_key": self.api_key, "is_admin": self.is_admin, "is_muted": self.is_muted, "is_tester": self.is_tester, "has_password": self.has_password, + "has_api_key": self.has_api_key, + "api_key_expires_at": self.api_key_expires_at, # 'notified_at': self.notified_at } ) @@ -192,6 +199,17 @@ def by_api_key(cls, api_key): return None q = cls.all() q = q.filter_by(api_key=api_key) + utcnow = datetime.now(timezone.utc) + + # TODO: Exclude API keys without expiration date after deadline + # See https://github.com/alephdata/aleph/issues/3729 + q = q.filter( + or_( + cls.api_key_expires_at == None, # noqa: E711 + utcnow < cls.api_key_expires_at, + ) + ) + q = q.filter(cls.type == cls.USER) q = q.filter(cls.is_blocked == False) # noqa return q.first() @@ -211,9 +229,6 @@ def load_or_create(cls, foreign_id, type_, name, email=None, is_admin=False): role.is_blocked = False role.notified_at = datetime.utcnow() - if role.api_key is None: - role.api_key = make_token() - if email is not None: role.email = email diff --git a/aleph/templates/email/api_key_expired.html b/aleph/templates/email/api_key_expired.html new file mode 100644 index 0000000000..43d49841ea --- /dev/null +++ b/aleph/templates/email/api_key_expired.html @@ -0,0 +1,15 @@ +{% extends "email/layout.html" %} + +{% block content -%} +

+ {% trans expires_at=(expires_at | datetimeformat) -%} + Your Aleph API key has expired on {{expires_at}} UTC. + {%- endtrans %} +

+ +

+ {% trans settings_url=settings_url -%} + If you do not use the Aleph API, or only use the API to access public data you can ignore this email. If you use the Aleph API to access data that is not publicly accessible then you’ll need to regenerate your API key to maintain access. + {%- endtrans %} +

+{%- endblock %} \ No newline at end of file diff --git a/aleph/templates/email/api_key_expired.txt b/aleph/templates/email/api_key_expired.txt new file mode 100644 index 0000000000..c2b96ab6d3 --- /dev/null +++ b/aleph/templates/email/api_key_expired.txt @@ -0,0 +1,11 @@ +{% extends "email/layout.txt" %} + +{% block content -%} +{% trans expires_at=(expires_at | datetimeformat) -%} +Your Aleph API key has expired on {{expires_at}} UTC. +{%- endtrans %} + +{% trans settings_url=settings_url -%} +If you do not use the Aleph API, or only use the API to access public data you can ignore this email. If you use the Aleph API to access data that is not publicly accessible then you’ll need to regenerate your API key ({{settings_url}}) to maintain access. +{%- endtrans %} +{%- endblock %} \ No newline at end of file diff --git a/aleph/templates/email/api_key_expires_soon.html b/aleph/templates/email/api_key_expires_soon.html new file mode 100644 index 0000000000..d6163e6fd5 --- /dev/null +++ b/aleph/templates/email/api_key_expires_soon.html @@ -0,0 +1,15 @@ +{% extends "email/layout.html" %} + +{% block content -%} +

+ {% trans expires_at=(expires_at | datetimeformat) -%} + Your Aleph API key will expire in 7 days, on {{expires_at}} UTC. + {%- endtrans %} +

+ +

+ {% trans settings_url=settings_url -%} + If you do not use the Aleph API, or only use the API to access public data you can ignore this email. If you use the Aleph API to access data that is not publicly accessible then you’ll need to regenerate your API key to maintain access. + {%- endtrans %} +

+{%- endblock %} \ No newline at end of file diff --git a/aleph/templates/email/api_key_expires_soon.txt b/aleph/templates/email/api_key_expires_soon.txt new file mode 100644 index 0000000000..589fcf11f6 --- /dev/null +++ b/aleph/templates/email/api_key_expires_soon.txt @@ -0,0 +1,11 @@ +{% extends "email/layout.txt" %} + +{% block content -%} +{% trans expires_at=(expires_at | datetimeformat) -%} +Your Aleph API key will expire in 7 days, on {{expires_at}} UTC. +{%- endtrans %} + +{% trans -%} +If you do not use the Aleph API, or only use the API to access public data you can ignore this email. If you use the Aleph API to access data that is not publicly accessible then you’ll need to regenerate your API key ({{settings_url}}) to maintain access. +{%- endtrans %} +{%- endblock %} \ No newline at end of file diff --git a/aleph/templates/email/api_key_generated.html b/aleph/templates/email/api_key_generated.html new file mode 100644 index 0000000000..813e82bd5d --- /dev/null +++ b/aleph/templates/email/api_key_generated.html @@ -0,0 +1,13 @@ +{% extends "email/layout.html" %} + +{% block content -%} +{% if event == "regenerated" -%} +{% trans -%} +Your Aleph API key has been regenerated. If that wasn’t you, please contact an administrator. +{%- endtrans %} +{% else -%} +{% trans -%} +An Aleph API key has been generated for your account. If that wasn’t you, please contact an administrator. +{%- endtrans %} +{%- endif %} +{%- endblock %} \ No newline at end of file diff --git a/aleph/templates/email/api_key_generated.txt b/aleph/templates/email/api_key_generated.txt new file mode 100644 index 0000000000..861474ba98 --- /dev/null +++ b/aleph/templates/email/api_key_generated.txt @@ -0,0 +1,13 @@ +{% extends "email/layout.txt" %} + +{% block content -%} +{% if event == "regenerated" -%} +{% trans -%} +Your Aleph API key has been regenerated. If that wasn’t you, please contact an administrator. +{%- endtrans %} +{% else -%} +{% trans -%} +An Aleph API key has been generated for your account. If that wasn’t you, please contact an administrator. +{%- endtrans %} +{%- endif %} +{%- endblock %} \ No newline at end of file diff --git a/aleph/tests/test_api_keys.py b/aleph/tests/test_api_keys.py new file mode 100644 index 0000000000..62c494a25f --- /dev/null +++ b/aleph/tests/test_api_keys.py @@ -0,0 +1,195 @@ +import datetime +import time_machine + +from aleph.core import db, mail +from aleph.logic.api_keys import ( + generate_user_api_key, + send_api_key_expiration_notifications, +) +from aleph.tests.util import TestCase + + +class ApiKeysTestCase(TestCase): + def test_generate_user_api_key(self): + role = self.create_user() + assert role.api_key is None + assert role.api_key_expires_at is None + + with time_machine.travel("2024-01-01T00:00:00Z"): + generate_user_api_key(role) + db.session.refresh(role) + assert role.api_key is not None + assert role.api_key_expires_at.date() == datetime.date(2024, 3, 31) + + old_key = role.api_key + + with time_machine.travel("2024-02-01T00:00:00Z"): + generate_user_api_key(role) + db.session.refresh(role) + assert role.api_key != old_key + assert role.api_key_expires_at.date() == datetime.date(2024, 5, 1) + + def test_generate_user_api_key_notification(self): + role = self.create_user(email="john.doe@example.org") + assert role.api_key is None + + with mail.record_messages() as outbox: + assert len(outbox) == 0 + generate_user_api_key(role) + assert len(outbox) == 1 + + msg = outbox[0] + assert msg.recipients == ["john.doe@example.org"] + assert msg.subject == "[Aleph] API key generated" + assert "An Aleph API key has been generated for your account" in msg.body + assert "An Aleph API key has been generated for your account" in msg.html + + with mail.record_messages() as outbox: + assert len(outbox) == 0 + generate_user_api_key(role) + assert len(outbox) == 1 + + msg = outbox[0] + assert msg.recipients == ["john.doe@example.org"] + assert msg.subject == "[Aleph] API key regenerated" + assert "Your Aleph API key has been regenerated" in msg.body + assert "Your Aleph API key has been regenerated" in msg.html + + def test_send_api_key_expiration_notifications(self): + role = self.create_user(email="john.doe@example.org") + + with mail.record_messages() as outbox: + with time_machine.travel("2024-01-01T00:00:00Z"): + assert len(outbox) == 0 + generate_user_api_key(role) + assert len(outbox) == 1 + assert outbox[0].subject == "[Aleph] API key generated" + + assert role.api_key is not None + assert role.api_key_expires_at.date() == datetime.date(2024, 3, 31) + + assert len(outbox) == 1 + send_api_key_expiration_notifications() + assert len(outbox) == 1 + + # A notification is sent 7 days before the notification date + with time_machine.travel("2024-03-24T00:00:00Z"): + assert len(outbox) == 1 + send_api_key_expiration_notifications() + assert len(outbox) == 2 + + msg = outbox[-1] + assert msg.recipients == ["john.doe@example.org"] + assert msg.subject == "[Aleph] Your API key will expire in 7 days" + assert ( + "Your Aleph API key will expire in 7 days, on Mar 31, 2024, 12:00:00\u202fAM UTC." + in msg.body + ) + assert ( + "Your Aleph API key will expire in 7 days, on Mar 31, 2024, 12:00:00\u202fAM UTC." + in msg.html + ) + + # The notification is sent only once + with time_machine.travel("2024-03-25T00:00:00Z"): + assert len(outbox) == 2 + send_api_key_expiration_notifications() + assert len(outbox) == 2 + + # Another notification is sent when the key has expired + with time_machine.travel("2024-03-31T00:00:00Z"): + assert len(outbox) == 2 + send_api_key_expiration_notifications() + assert len(outbox) == 3 + + msg = outbox[-1] + assert msg.recipients == ["john.doe@example.org"] + assert msg.subject == "[Aleph] Your API key has expired" + assert ( + "Your Aleph API key has expired on Mar 31, 2024, 12:00:00\u202fAM UTC." + in msg.body + ) + assert ( + "Your Aleph API key has expired on Mar 31, 2024, 12:00:00\u202fAM UTC." + in msg.html + ) + + # The notification is sent only once + with time_machine.travel("2024-03-31T00:00:00Z"): + assert len(outbox) == 3 + send_api_key_expiration_notifications() + assert len(outbox) == 3 + + def test_send_api_key_expiration_notifications_no_key(self): + role = self.create_user(email="john.doe@example.org") + assert role.api_key is None + + with mail.record_messages() as outbox: + assert len(outbox) == 0 + send_api_key_expiration_notifications() + assert len(outbox) == 0 + + def test_send_api_key_expiration_notifications_delay(self): + role = self.create_user(email="john.doe@example.org") + + with mail.record_messages() as outbox: + with time_machine.travel("2024-01-01T00:00:00Z"): + assert len(outbox) == 0 + generate_user_api_key(role) + assert len(outbox) == 1 + assert outbox[0].subject == "[Aleph] API key generated" + + # Notifications are sent even if the task that sends them is executed with a delay, + # for example 6 days before the key expires + with time_machine.travel("2024-03-26T00:00:00Z"): + assert len(outbox) == 1 + send_api_key_expiration_notifications() + assert len(outbox) == 2 + + msg = outbox[-1] + assert msg.recipients == ["john.doe@example.org"] + assert msg.subject == "[Aleph] Your API key will expire in 7 days" + + # 1 day after the key has expired + with time_machine.travel("2024-04-01T00:00:00Z"): + assert len(outbox) == 2 + send_api_key_expiration_notifications() + assert len(outbox) == 3 + + msg = outbox[-1] + assert msg.recipients == ["john.doe@example.org"] + assert msg.subject == "[Aleph] Your API key has expired" + + def test_send_api_key_expiration_notifications_regenerate(self): + role = self.create_user(email="john.doe@example.org") + + with mail.record_messages() as outbox: + with time_machine.travel("2024-01-01T00:00:00Z"): + assert len(outbox) == 0 + generate_user_api_key(role) + assert len(outbox) == 1 + assert outbox[0].subject == "[Aleph] API key generated" + + # 90 days after generating the initial API key + with time_machine.travel("2024-03-31T00:00:00Z"): + assert len(outbox) == 1 + send_api_key_expiration_notifications() + assert len(outbox) == 3 + + assert outbox[1].subject == "[Aleph] Your API key will expire in 7 days" + assert outbox[2].subject == "[Aleph] Your API key has expired" + + # Regenerate the key after it has expired + assert len(outbox) == 3 + generate_user_api_key(role) + assert len(outbox) == 4 + assert outbox[3].subject == "[Aleph] API key regenerated" + + # 90 days after regenerating the API key + with time_machine.travel("2024-06-29T00:00:00Z"): + assert len(outbox) == 4 + send_api_key_expiration_notifications() + assert len(outbox) == 6 + + assert outbox[4].subject == "[Aleph] Your API key will expire in 7 days" + assert outbox[5].subject == "[Aleph] Your API key has expired" diff --git a/aleph/tests/test_groups_api.py b/aleph/tests/test_groups_api.py index 7287823aa0..a00e4880fc 100644 --- a/aleph/tests/test_groups_api.py +++ b/aleph/tests/test_groups_api.py @@ -15,11 +15,24 @@ def setUp(self): def test_index(self): res = self.client.get("/api/2/groups") assert res.status_code == 403, res + _, headers = self.login(foreign_id="user_1") res = self.client.get("/api/2/groups", headers=headers) assert res.status_code == 200, res assert res.json["total"] == 2, res.json validate(res.json["results"][0], "Role") + assert set(res.json["results"][0].keys()) == { + "id", + "created_at", + "updated_at", + "type", + "name", + "label", + "writeable", + "shallow", + "links", + } + _, headers = self.login(foreign_id="other") res = self.client.get("/api/2/groups", headers=headers) assert res.status_code == 200, res diff --git a/aleph/tests/test_permissions_api.py b/aleph/tests/test_permissions_api.py index 6b3229afc2..113d8936f1 100644 --- a/aleph/tests/test_permissions_api.py +++ b/aleph/tests/test_permissions_api.py @@ -94,6 +94,16 @@ def test_update_groups(self): assert res.json["results"][0]["role"]["id"] == str(self.role.id) self.role.add_role(group) + + # The user's group memberships are cached in Redis when the user signs in. + # As we've updated the group memberships, we need to reauthenticate the + # user to update the cache. + self.role, self.headers = self.login( + foreign_id="john", + name="John Doe", + email="john.doe@example.org", + ) + res = self.client.put(url, headers=self.headers, json=data) assert res.status_code == 200 assert len(res.json["results"]) == 2 diff --git a/aleph/tests/test_role_model.py b/aleph/tests/test_role_model.py index 80e6aa8946..5ad24cc1e9 100644 --- a/aleph/tests/test_role_model.py +++ b/aleph/tests/test_role_model.py @@ -1,3 +1,6 @@ +import datetime +import time_machine + from aleph.core import db from aleph.model import Role from aleph.tests.factories.models import RoleFactory @@ -77,3 +80,53 @@ def test_remove_role(self): # Remove the user from the group group_role.remove_role(user_role) assert user_role not in group_role.roles + + def test_role_by_api_key(self): + role_ = self.create_user() + role_.api_key = "1234567890" + db.session.add(role_) + db.session.commit() + + role = Role.by_api_key("1234567890") + assert role is not None + assert role.id == role_.id + + def test_role_by_api_key_empty(self): + role_ = self.create_user() + assert role_.api_key is None + + role = Role.by_api_key(None) + assert role is None + + role = Role.by_api_key("") + assert role is None + + def test_role_by_api_key_expired(self): + role_ = self.create_user() + role_.api_key = "1234567890" + role_.api_key_expires_at = datetime.datetime(2024, 3, 31, 0, 0, 0) + db.session.add(role_) + db.session.commit() + + with time_machine.travel("2024-03-30T23:59:59Z"): + print(role_.api_key_expires_at) + role = Role.by_api_key(role_.api_key) + assert role is not None + assert role.id == role_.id + + with time_machine.travel("2024-03-31T00:00:00Z"): + role = Role.by_api_key(role_.api_key) + assert role is None + + def test_role_by_api_key_legacy_without_expiration(self): + # Ensure that legacy API keys that were created without an expiration + # date continue to work. + role_ = self.create_user() + role_.api_key = "1234567890" + role_.api_key_expires_at = None + db.session.add(role_) + db.session.commit() + + role = Role.by_api_key("1234567890") + assert role is not None + assert role.id == role_.id diff --git a/aleph/tests/test_roles_api.py b/aleph/tests/test_roles_api.py index 1ca37f43fa..8a24d21529 100644 --- a/aleph/tests/test_roles_api.py +++ b/aleph/tests/test_roles_api.py @@ -1,5 +1,7 @@ import json +import time_machine + from aleph.core import db, mail from aleph.settings import SETTINGS from aleph.model import Role @@ -67,13 +69,80 @@ def test_suggest(self): assert res.json["total"] == 0 assert len(res.json["results"]) == 0 + def test_view_auth(self): + _, other_headers = self.login(foreign_id="other") + role, _ = self.login( + foreign_id="john", + name="John Doe", + email="john@example.org", + ) + + # Unauthenticated request + res = self.client.get(f"/api/2/roles/{role.id}") + assert res.status_code == 403 + + # Authenticated but unauthorized request + res = self.client.get(f"/api/2/roles/{role.id}", headers=other_headers) + assert res.status_code == 403 + def test_view(self): - res = self.client.get("/api/2/roles/%s" % self.rolex) - assert res.status_code == 404, res - role, headers = self.login() - res = self.client.get("/api/2/roles/%s" % role.id, headers=headers) - assert res.status_code == 200, res - # assert res.json['total'] >= 6, res.json + role, headers = self.login( + foreign_id="john", + name="John Doe", + email="john@example.org", + ) + + # Authenticated and authorized request + res = self.client.get(f"/api/2/roles/{role.id}", headers=headers) + assert res.status_code == 200 + + assert set(res.json.keys()) == { + "id", + "type", + "name", + "email", + "label", + "created_at", + "updated_at", + "is_admin", + "is_muted", + "is_tester", + "has_password", + "has_api_key", + "counts", + "shallow", + "writeable", + "links", + } + + assert res.json["id"] == str(role.id) + assert res.json["type"] == "user" + assert res.json["name"] == "John Doe" + assert res.json["email"] == "john@example.org" + assert res.json["label"] == "John Doe " + assert res.json["is_admin"] is False + assert res.json["shallow"] is False + assert res.json["writeable"] is True + + def test_view_api_key(self): + role, headers = self.login(foreign_id="john", email="john.doe@example.org") + + res = self.client.get(f"/api/2/roles/{role.id}", headers=headers) + assert res.status_code == 200 + assert res.json["has_api_key"] is False + assert "api_key_expires_at" not in res.json + + with time_machine.travel("2024-01-01T00:00:00Z"): + res = self.client.post( + f"/api/2/roles/{role.id}/generate_api_key", + headers=headers, + ) + assert res.status_code == 200 + + res = self.client.get(f"/api/2/roles/{role.id}", headers=headers) + assert res.status_code == 200 + assert res.json["has_api_key"] is True + assert res.json["api_key_expires_at"] == "2024-03-31T00:00:00" def test_update(self): res = self.client.post("/api/2/roles/%s" % self.rolex) @@ -183,3 +252,65 @@ def test_create_on_existing_email(self): res = self.client.post("/api/2/roles", data=payload) self.assertEqual(res.status_code, 409) + + def test_generate_api_key_auth(self): + url = f"/api/2/roles/{self.rolex.id}/generate_api_key" + + # Anonymous request + res = self.client.post(url) + self.assertEqual(res.status_code, 403) + + # Authenticated request, but for a different role + _, headers = self.login() + res = self.client.post(url, headers=headers) + self.assertEqual(res.status_code, 403) + + def test_generate_api_key(self): + role, headers = self.login() + assert role.api_key is None + + # Generate initial API key for new user + url = f"/api/2/roles/{role.id}/generate_api_key" + res = self.client.post(url, headers=headers) + new_key = res.json["api_key"] + assert res.status_code == 200 + assert new_key is not None + + # The new API key can be used for authentication + url = f"/api/2/roles/{role.id}" + res = self.client.get(url, headers={"Authorization": new_key}) + assert res.status_code == 200 + + old_key = new_key + + # Regenerate API key + url = f"/api/2/roles/{role.id}/generate_api_key" + res = self.client.post(url, headers=headers) + new_key = res.json["api_key"] + + assert res.status_code == 200 + assert new_key is not None + assert new_key != old_key + + # Old key cannot be used for authentication anymore + url = f"/api/2/roles/{role.id}" + res = self.client.get(url, headers={"Authorization": old_key}) + self.assertEqual(res.status_code, 403) + + # New key can be used for authentication + res = self.client.get(url, headers={"Authorization": new_key}) + self.assertEqual(res.status_code, 200) + + def test_new_roles_no_api_key(self): + SETTINGS.PASSWORD_LOGIN = True + email = "john.doe@example.org" + data = { + "password": "12345678", + "code": Role.SIGNATURE.dumps(email), + } + res = self.client.post("/api/2/roles", data=data) + assert res.status_code == 201 + + role = Role.by_email(email) + assert role is not None + assert role.api_key is None diff --git a/aleph/tests/test_sessions_api.py b/aleph/tests/test_sessions_api.py index fcef587cb3..7a36321609 100644 --- a/aleph/tests/test_sessions_api.py +++ b/aleph/tests/test_sessions_api.py @@ -11,14 +11,13 @@ from aleph.logic.collections import update_collection from aleph.views.base_api import _metadata_locale from aleph.tests.util import TestCase -from aleph.tests.factories.models import RoleFactory from aleph.oauth import oauth class SessionsApiTestCase(TestCase): def setUp(self): super().setUp() - self.role = RoleFactory.create() + self.role = self.create_user() def test_admin_all_access(self): self.wl = Collection() @@ -122,6 +121,22 @@ def test_password_login_post_blocked_user(self): assert res.status_code == 403, res assert res.json["message"] == "Your account has been blocked." + def test_password_login_no_api_key(self): + SETTINGS.PASSWORD_LOGIN = True + secret = self.fake.password() + self.role.set_password(secret) + data = { + "email": self.role.email, + "password": secret, + } + assert self.role.api_key is None + + res = self.client.post("/api/2/sessions/login", data=data) + assert res.status_code == 200 + + db.session.refresh(self.role) + assert self.role.api_key is None + class SessionsApiOAuthTestCase(TestCase): def setUp(self): diff --git a/aleph/tests/test_view_context.py b/aleph/tests/test_view_context.py new file mode 100644 index 0000000000..f6aa460c7a --- /dev/null +++ b/aleph/tests/test_view_context.py @@ -0,0 +1,98 @@ +from aleph.core import db +from aleph.tests.util import TestCase + + +class ViewContextTest(TestCase): + def setUp(self): + super().setUp() + self.role = self.create_user(email="john.doe@example.org") + self.role.set_password("12345678") + self.role.api_key = "1234567890" + + self.other_role = self.create_user( + foreign_id="other", + email="jane.doe@example.org", + ) + assert self.other_role.api_key is None + + db.session.add(self.role) + db.session.add(self.other_role) + db.session.commit() + + def test_authz_header_session_token(self): + data = { + "email": "john.doe@example.org", + "password": "12345678", + } + res = self.client.post("/api/2/sessions/login", data=data) + assert res.status_code == 200 + token = res.json["token"] + + headers = {"Authorization": f"Token {token}"} + res = self.client.get(f"/api/2/roles/{self.role.id}", headers=headers) + assert res.status_code == 200 + assert res.json["email"] == "john.doe@example.org" + + def test_authz_header_session_token_invalid(self): + headers = {"Authorization": "Token INVALID"} + res = self.client.get("/api/2/metadata", headers=headers) + assert res.status_code == 401 + + headers = {"Authorization": "Token INVALID"} + res = self.client.get(f"/api/2/roles/{self.role.id}", headers=headers) + assert res.status_code == 401 + + headers = {"Authorization": "Token "} + res = self.client.get(f"/api/2/roles/{self.role.id}", headers=headers) + assert res.status_code == 401 + + def test_authz_header_api_key(self): + headers = {"Authorization": f"ApiKey {self.role.api_key}"} + res = self.client.get(f"/api/2/roles/{self.role.id}", headers=headers) + assert res.status_code == 200 + assert res.json["email"] == "john.doe@example.org" + + headers = {"Authorization": self.role.api_key} + res = self.client.get(f"/api/2/roles/{self.role.id}", headers=headers) + assert res.status_code == 200 + assert res.json["email"] == "john.doe@example.org" + + def test_authz_header_api_key_invalid(self): + # The API behavior is a little weird in this case. When passing an invalid API key we do + # not immediately raise an auth error. Instead, we treat the request as an unauthenticated/ + # anonymous request. Only when trying to access a protected resource, we raise a 403 error. + # Keeping this behavior for backwards compatibility. + headers = {"Authorization": "ApiKey INVALID"} + res = self.client.get("/api/2/metadata", headers=headers) + assert res.status_code == 200 + + headers = {"Authorization": "ApiKey INVALID"} + res = self.client.get(f"/api/2/roles/{self.role.id}", headers=headers) + assert res.status_code == 403 + + headers = {"Authorization": "ApiKey "} + res = self.client.get(f"/api/2/roles/{self.role.id}", headers=headers) + assert res.status_code == 403 + + headers = {"Authorization": ""} + res = self.client.get(f"/api/2/roles/{self.role.id}", headers=headers) + assert res.status_code == 403 + + headers = {"Authorization": "INVALID"} + res = self.client.get(f"/api/2/roles/{self.role.id}", headers=headers) + assert res.status_code == 403 + + def test_authz_url_param_api_key(self): + query_string = {"api_key": self.role.api_key} + res = self.client.get(f"/api/2/roles/{self.role.id}", query_string=query_string) + assert res.status_code == 200 + assert res.json["email"] == "john.doe@example.org" + + def test_authz_url_params_api_key_invalid(self): + query_string = {"api_key": "INVALID"} + res = self.client.get(f"/api/2/roles/{self.role.id}", query_string=query_string) + assert res.status_code == 403 + + query_string = {"api_key": ""} + res = self.client.get(f"/api/2/roles/{self.role.id}", query_string=query_string) + assert res.status_code == 403 diff --git a/aleph/tests/util.py b/aleph/tests/util.py index ed4bfced58..878fb77661 100644 --- a/aleph/tests/util.py +++ b/aleph/tests/util.py @@ -137,7 +137,11 @@ def login(self, foreign_id="tester", name=None, email=None, is_admin=False): role = self.create_user( foreign_id=foreign_id, name=name, email=email, is_admin=is_admin ) - headers = {"Authorization": role.api_key} + + authz = Authz.from_role(role) + token = authz.to_token() + + headers = {"Authorization": f"Token {token}"} return role, headers def create_collection(self, creator=None, **kwargs): diff --git a/aleph/views/roles_api.py b/aleph/views/roles_api.py index a22546ad9c..b15c662d24 100644 --- a/aleph/views/roles_api.py +++ b/aleph/views/roles_api.py @@ -11,6 +11,7 @@ from aleph.search import QueryParser, DatabaseQueryResult from aleph.model import Role from aleph.logic.roles import challenge_role, update_role, create_user, get_deep_role +from aleph.logic.api_keys import generate_user_api_key from aleph.util import is_auto_admin from aleph.views.serializers import RoleSerializer from aleph.views.util import require, jsonify, parse_request, obj_or_404 @@ -244,3 +245,40 @@ def update(id): db.session.commit() update_role(role) return RoleSerializer.jsonify(role) + + +@blueprint.route("/api/2/roles//generate_api_key", methods=["POST"]) +def generate_api_key(id): + """Reset the role’s API key. + --- + post: + summary: Reset API key + description: > + Reset the role’s API key. This will invalidate the current + API key and generate a new one. + parameters: + - in: path + name: id + required: true + description: role ID + schema: + type: integer + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Role' + tags: + - Role + """ + role = obj_or_404(Role.by_id(id)) + require(request.authz.can_write_role(role.id)) + api_key = generate_user_api_key(role) + data = RoleSerializer().serialize(role) + # The API key usually isn’t included in API responses, but we return it + # exactly once after it has been (re-)generated. + data["api_key"] = api_key + + return jsonify(data) diff --git a/aleph/views/serializers.py b/aleph/views/serializers.py index fb9e7856dd..2b3e3f0aa7 100644 --- a/aleph/views/serializers.py +++ b/aleph/views/serializers.py @@ -118,19 +118,27 @@ def _serialize(self, obj): obj["shallow"] = obj.get("shallow", True) if self.nested or not obj["writeable"]: obj.pop("has_password", None) + obj.pop("has_api_key", None) + obj.pop("api_key_expires_at", None) obj.pop("is_admin", None) obj.pop("is_muted", None) obj.pop("is_tester", None) obj.pop("is_blocked", None) - obj.pop("api_key", None) obj.pop("email", None) obj.pop("locale", None) obj.pop("created_at", None) obj.pop("updated_at", None) if obj["type"] != Role.USER: - obj.pop("api_key", None) + obj.pop("has_password", None) + obj.pop("has_api_key", None) + obj.pop("api_key_expires_at", None) + obj.pop("is_admin", None) + obj.pop("is_muted", None) + obj.pop("is_tester", None) + obj.pop("is_blocked", None) obj.pop("email", None) obj.pop("locale", None) + obj.pop("api_key", None) obj.pop("password", None) return obj diff --git a/aleph/worker.py b/aleph/worker.py index 5818e5ad02..220ccaabe7 100644 --- a/aleph/worker.py +++ b/aleph/worker.py @@ -18,6 +18,7 @@ from aleph.model import Collection from aleph.queues import get_rate_limit from aleph.logic.alerts import check_alerts +from aleph.logic.api_keys import send_api_key_expiration_notifications from aleph.logic.collections import reingest_collection, reindex_collection from aleph.logic.collections import compute_collections, refresh_collection from aleph.logic.notifications import generate_digest, delete_old_notifications @@ -152,6 +153,7 @@ def periodic(self): update_roles() check_alerts() generate_digest() + send_api_key_expiration_notifications() delete_expired_exports() delete_old_notifications() diff --git a/ui/src/actions/index.js b/ui/src/actions/index.js index 48b2fb67e2..3e8ed2f550 100644 --- a/ui/src/actions/index.js +++ b/ui/src/actions/index.js @@ -1,6 +1,12 @@ import { createAction } from 'redux-act'; -export { queryRoles, fetchRole, suggestRoles, updateRole } from './roleActions'; +export { + queryRoles, + fetchRole, + suggestRoles, + updateRole, + generateApiKey, +} from './roleActions'; export { createAlert, deleteAlert, queryAlerts } from './alertActions'; export { queryNotifications } from './notificationActions'; export { setConfigValue, dismissHint } from './configActions'; diff --git a/ui/src/actions/roleActions.js b/ui/src/actions/roleActions.js index fd0c93d378..7ba2c30728 100644 --- a/ui/src/actions/roleActions.js +++ b/ui/src/actions/roleActions.js @@ -32,3 +32,11 @@ export const updateRole = asyncActionCreator( }, { name: 'UPDATE_ROLE' } ); + +export const generateApiKey = asyncActionCreator( + (role) => async () => { + const response = await endpoint.post(`roles/${role.id}/generate_api_key`); + return { id: role.id, data: response.data }; + }, + { name: 'GENERATE_API_KEY' } +); diff --git a/ui/src/app/App.scss b/ui/src/app/App.scss index 064f045dfb..cb82bce4f1 100644 --- a/ui/src/app/App.scss +++ b/ui/src/app/App.scss @@ -141,8 +141,11 @@ a { ); } -/* This causes toasts to appear *below* the navbar instead of on top of it */ .aleph-toaster { + /* Hacky workaround to ensure toasts are visible even when scrolling */ + position: fixed !important; + + /* This causes toasts to appear *below* the navbar instead of on top of it */ margin-top: $aleph-grid-size * 5; } diff --git a/ui/src/components/Settings/ApiKeySettings.tsx b/ui/src/components/Settings/ApiKeySettings.tsx new file mode 100644 index 0000000000..0c07a61c12 --- /dev/null +++ b/ui/src/components/Settings/ApiKeySettings.tsx @@ -0,0 +1,254 @@ +import { useReducer } from 'react'; +import { useSelector, useDispatch } from 'react-redux'; +import { Button, Intent, Dialog, DialogBody, Card } from '@blueprintjs/core'; +import { ClipboardInput } from 'components/common'; +import { selectCurrentRole } from 'selectors'; +import { generateApiKey as generateApiKeyAction } from 'actions'; +import { FormattedMessage, FormattedDate, FormattedTime } from 'react-intl'; +import convertUTCDateToLocalDate from 'util/convertUTCDateToLocalDate'; + +const GENERATE_MESSAGE = ( + +); + +const REGENERATE_MESSAGE = ( + +); + +type State = { + state: + | 'START' + | 'REGENERATE_CONFIRMATION' + | 'REGENERATE_LOADING' + | 'REGENERATE_DONE' + | 'GENERATE_LOADING' + | 'GENERATE_DONE'; + apiKey?: string; +}; + +type RegenerateConfirmAction = { type: 'REGENERATE_CONFIRM' }; +type RegenerateStartAction = { type: 'REGENERATE_START' }; +type RegenerateEndAction = { type: 'REGENERATE_END'; apiKey: string }; +type GenerateStartAction = { type: 'GENERATE_START' }; +type GenerateEndAction = { type: 'GENERATE_END'; apiKey: string }; +type CloseAction = { type: 'CLOSE' }; + +type Action = + | RegenerateConfirmAction + | RegenerateStartAction + | RegenerateEndAction + | GenerateStartAction + | GenerateEndAction + | CloseAction; + +const STATE_TRANSITIONS: Record = { + REGENERATE_CONFIRM: 'REGENERATE_CONFIRMATION', + REGENERATE_START: 'REGENERATE_LOADING', + REGENERATE_END: 'REGENERATE_DONE', + GENERATE_START: 'GENERATE_LOADING', + GENERATE_END: 'GENERATE_DONE', + CLOSE: 'START', +}; + +function reducer(current: State, action: Action): State { + const next = { + state: STATE_TRANSITIONS[action.type], + apiKey: current.apiKey, + }; + + if (action.type === 'REGENERATE_END' || action.type === 'GENERATE_END') { + next.apiKey = action.apiKey; + } + + if (action.type === 'CLOSE') { + next.apiKey = undefined; + } + + return next; +} + +export default function ApiKeySettings() { + const reduxDispatch = useDispatch(); + + const role = useSelector(selectCurrentRole); + const [{ state, apiKey }, dispatch] = useReducer(reducer, { state: 'START' }); + + const generateApiKey = async () => { + const { data } = await reduxDispatch(generateApiKeyAction(role)); + return data.api_key; + }; + + const onGenerate = async () => { + dispatch({ type: 'GENERATE_START' }); + const apiKey = await generateApiKey(); + dispatch({ type: 'GENERATE_END', apiKey }); + }; + + const onRegenerate = () => { + dispatch({ type: 'REGENERATE_CONFIRM' }); + }; + + const onConfirm = async () => { + dispatch({ type: 'REGENERATE_START' }); + const apiKey = await generateApiKey(); + dispatch({ type: 'REGENERATE_END', apiKey }); + }; + + const onClose = async () => { + dispatch({ type: 'CLOSE' }); + }; + + const expiresAt = + role.api_key_expires_at && + convertUTCDateToLocalDate(new Date(role.api_key_expires_at)); + const now = new Date(); + const hasExpired = expiresAt <= now; + + return ( + <> + +

+ +

+ + {role.has_api_key && expiresAt && ( +

+ {hasExpired ? ( + , + time: , + }} + /> + ) : ( + , + time: , + }} + /> + )} +

+ )} + +

+ {role.has_api_key ? ( + hasExpired ? ( + + ) : ( + + ) + ) : ( + + )} +

+ + +
+ + + + ); +} + +type ApiKeyDialogProps = { + state: State['state']; + apiKey?: string; + isOpen: boolean; + onConfirm: () => void; + onClose: () => void; +}; + +function ApiKeyDialog({ + state, + apiKey, + isOpen, + onConfirm, + onClose, +}: ApiKeyDialogProps) { + if (state === 'REGENERATE_CONFIRMATION' || state === 'REGENERATE_LOADING') { + return ( + + +

+ +

+ +
+
+ ); + } + + const title = + state === 'REGENERATE_DONE' ? REGENERATE_MESSAGE : GENERATE_MESSAGE; + + return ( + + +

+ +

+

+ +

+
+
+ ); +} diff --git a/ui/src/components/Settings/LanguageSettings.jsx b/ui/src/components/Settings/LanguageSettings.jsx new file mode 100644 index 0000000000..b137944b59 --- /dev/null +++ b/ui/src/components/Settings/LanguageSettings.jsx @@ -0,0 +1,64 @@ +import { useState } from 'react'; +import { Classes, Card, HTMLSelect } from '@blueprintjs/core'; +import { FormattedMessage, useIntl } from 'react-intl'; +import { useSelector, useDispatch } from 'react-redux'; +import { updateRole } from 'actions'; +import { selectMetadata, selectCurrentRole } from 'selectors'; +import { showSuccessToast } from 'app/toast'; + +export default function LanguageSettings() { + const dispatch = useDispatch(); + const metadata = useSelector(selectMetadata); + const currentRole = useSelector(selectCurrentRole); + const [isLoading, setIsLoading] = useState(false); + + const options = Object.entries(metadata.app.locales).map(([code, label]) => ({ + value: code, + label, + })); + + const intl = useIntl(); + const successMessage = intl.formatMessage({ + id: 'settings.locale.success', + defaultMessage: 'Your language preference has been updated.', + }); + + const updateLocale = async (locale) => { + const role = { + id: currentRole.id, + locale, + }; + + setIsLoading(true); + await dispatch(updateRole(role)); + setIsLoading(false); + showSuccessToast(successMessage); + }; + + const localeMessage = ( + + ); + + return ( + +

{localeMessage}

+ +

+ +

+ + +
+ ); +} diff --git a/ui/src/components/Settings/NotificationSettings.jsx b/ui/src/components/Settings/NotificationSettings.jsx new file mode 100644 index 0000000000..3cfbbd5e17 --- /dev/null +++ b/ui/src/components/Settings/NotificationSettings.jsx @@ -0,0 +1,61 @@ +import { useState } from 'react'; +import { Classes, Card, Switch } from '@blueprintjs/core'; +import { FormattedMessage, useIntl } from 'react-intl'; +import { useSelector, useDispatch } from 'react-redux'; +import { updateRole } from 'actions'; +import { selectCurrentRole } from 'selectors'; +import { showSuccessToast } from 'app/toast'; + +export default function NotificationSettings() { + const dispatch = useDispatch(); + const currentRole = useSelector(selectCurrentRole); + const [isLoading, setIsLoading] = useState(false); + + const intl = useIntl(); + const successMessage = intl.formatMessage({ + id: 'settings.notifications.success', + defaultMessage: 'Your notification preferences have been updated.', + }); + + const updateNotifications = async (receiveNotifications) => { + const role = { + id: currentRole.id, + is_muted: !receiveNotifications, + }; + + setIsLoading(true); + await dispatch(updateRole(role)); + setIsLoading(false); + showSuccessToast(successMessage); + }; + + return ( + +

+ +

+ +

+ +

+ + + } + onChange={(event) => updateNotifications(event.target.checked)} + disabled={isLoading} + /> +
+ ); +} diff --git a/ui/src/components/Settings/PasswordSettings.jsx b/ui/src/components/Settings/PasswordSettings.jsx new file mode 100644 index 0000000000..4cfd5e1df3 --- /dev/null +++ b/ui/src/components/Settings/PasswordSettings.jsx @@ -0,0 +1,193 @@ +import { useState } from 'react'; +import { + Button, + Card, + DialogBody, + FormGroup, + InputGroup, + Intent, +} from '@blueprintjs/core'; +import { Dialog } from 'react-ftm'; +import { FormattedMessage, useIntl } from 'react-intl'; +import { useDispatch, useSelector } from 'react-redux'; +import { selectCurrentRole } from 'selectors'; +import { updateRole } from 'actions'; +import { showSuccessToast, showWarningToast } from 'react-ftm/utils'; + +export default function PasswordSettings() { + const dispatch = useDispatch(); + const currentRole = useSelector(selectCurrentRole); + + const [showDialog, setShowDialog] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [currentPassword, setCurrentPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [passwordConfirmation, setPasswordConfirmation] = useState(''); + + const changeMessage = ( + + ); + + const intl = useIntl(); + const successMessage = intl.formatMessage({ + id: 'settings.password.success', + defaultMessage: 'Your password has been updated.', + }); + + const isValidCurrentPassword = currentPassword !== ''; + const isValidNewPassword = newPassword && newPassword.length >= 6; + const isValidPasswordConfirmation = newPassword === passwordConfirmation; + + const newPasswordIntent = + newPassword && !isValidNewPassword ? Intent.DANGER : null; + const passwordConfirmationIntent = + passwordConfirmation && !isValidPasswordConfirmation ? Intent.DANGER : null; + + const onUpdate = async (event) => { + event.preventDefault(); + + const role = { + id: currentRole.id, + current_password: currentPassword, + password: newPassword, + }; + + setIsLoading(true); + + try { + await dispatch(updateRole(role)); + setShowDialog(false); + showSuccessToast(successMessage); + setCurrentPassword(''); + setNewPassword(''); + setPasswordConfirmation(''); + } catch (error) { + showWarningToast(error.message); + } + + setIsLoading(false); + }; + + return ( + <> + +

+ +

+ + +
+ + setShowDialog(false)} + > + +
+ + } + labelFor="current_password" + helperText={ + + } + > + setCurrentPassword(event.target.value)} + required + /> + + + + } + labelFor="new_password" + intent={newPasswordIntent} + helperText={ + + } + > + setNewPassword(event.target.value)} + intent={newPasswordIntent} + required + minLength={6} + /> + + + + } + labelFor="password_confirmation" + intent={passwordConfirmationIntent} + helperText={ + passwordConfirmation && + !isValidPasswordConfirmation && ( + + ) + } + > + setPasswordConfirmation(event.target.value)} + intent={passwordConfirmationIntent} + required + minLength={6} + /> + + + +
+
+
+ + ); +} diff --git a/ui/src/components/Settings/ProfileSettings.jsx b/ui/src/components/Settings/ProfileSettings.jsx new file mode 100644 index 0000000000..c871e846d0 --- /dev/null +++ b/ui/src/components/Settings/ProfileSettings.jsx @@ -0,0 +1,86 @@ +import { useState } from 'react'; +import { + Button, + Classes, + Card, + FormGroup, + InputGroup, +} from '@blueprintjs/core'; +import { FormattedMessage, useIntl } from 'react-intl'; +import { useSelector, useDispatch } from 'react-redux'; +import { updateRole } from 'actions'; +import { selectCurrentRole } from 'selectors'; +import { showSuccessToast } from 'app/toast'; + +export default function ProfileSettings() { + const dispatch = useDispatch(); + const currentRole = useSelector(selectCurrentRole); + const [isLoading, setIsLoading] = useState(false); + const [name, setName] = useState(currentRole.name); + + const intl = useIntl(); + const successMessage = intl.formatMessage({ + id: 'settings.saved', + defaultMessage: 'It’s official, your profile is updated.', + }); + + const onUpdate = async (event) => { + event.preventDefault(); + + const role = { + id: currentRole.id, + name, + }; + + setIsLoading(true); + await dispatch(updateRole(role)); + setIsLoading(false); + showSuccessToast(successMessage); + }; + + return ( + +

+ +

+ +
+ } + labelFor="name" + > + setName(event.target.value)} + /> + + + + } + labelFor="email" + helperText={ + + } + > + + + + +
+
+ ); +} diff --git a/ui/src/components/common/ClipboardInput.jsx b/ui/src/components/common/ClipboardInput.jsx index 79fcf944b3..145e6c754f 100644 --- a/ui/src/components/common/ClipboardInput.jsx +++ b/ui/src/components/common/ClipboardInput.jsx @@ -30,17 +30,20 @@ export default function ClipboardInput(props) { readOnly value={props.value} rightElement={ - -