diff --git a/superset-frontend/packages/superset-ui-core/src/components/Result/index.tsx b/superset-frontend/packages/superset-ui-core/src/components/Result/index.tsx new file mode 100644 index 00000000000..5f08c34865c --- /dev/null +++ b/superset-frontend/packages/superset-ui-core/src/components/Result/index.tsx @@ -0,0 +1,21 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { Result } from 'antd'; +export type { ResultProps } from 'antd'; diff --git a/superset-frontend/packages/superset-ui-core/src/components/index.ts b/superset-frontend/packages/superset-ui-core/src/components/index.ts index 25e56804fe1..6e5348f320d 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/index.ts +++ b/superset-frontend/packages/superset-ui-core/src/components/index.ts @@ -164,3 +164,4 @@ export * from './Table'; export * from './TableView'; export * from './Tag'; export * from './constants'; +export * from './Result'; diff --git a/superset-frontend/src/pages/Register/index.tsx b/superset-frontend/src/pages/Register/index.tsx index 2a32d3147d1..473bbf64743 100644 --- a/superset-frontend/src/pages/Register/index.tsx +++ b/superset-frontend/src/pages/Register/index.tsx @@ -18,10 +18,18 @@ */ import { SupersetClient, styled, t, css } from '@superset-ui/core'; -import { Button, Card, Flex, Form, Input } from '@superset-ui/core/components'; +import { + Button, + Card, + Flex, + Form, + Input, + Result, +} from '@superset-ui/core/components'; import { useState } from 'react'; import getBootstrapData from 'src/utils/getBootstrapData'; import ReactCAPTCHA from 'react-google-recaptcha'; +import { useParams } from 'react-router-dom'; interface RegisterForm { username: string; @@ -58,12 +66,36 @@ export default function Login() { const [form] = Form.useForm(); const [loading, setLoading] = useState(false); const [captchaResponse, setCaptchaResponse] = useState(null); + const { activationHash } = useParams<{ activationHash?: string }>(); const bootstrapData = getBootstrapData(); const authRecaptchaPublicKey: string = bootstrapData.common.conf.RECAPTCHA_PUBLIC_KEY || ''; + if (activationHash) { + return ( + + + {t('Login')} + , + ]} + /> + + ); + } + const onFinish = (values: RegisterForm) => { setLoading(true); const payload = { diff --git a/superset-frontend/src/pages/UserRegistrations/UserRegistrations.test.tsx b/superset-frontend/src/pages/UserRegistrations/UserRegistrations.test.tsx new file mode 100644 index 00000000000..547d9093fd1 --- /dev/null +++ b/superset-frontend/src/pages/UserRegistrations/UserRegistrations.test.tsx @@ -0,0 +1,55 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import fetchMock from 'fetch-mock'; +import { render, screen } from 'spec/helpers/testing-library'; +import UserRegistrations from '.'; + +const userRegistrationsEndpoint = 'glob:*/security/user_registrations/?*'; + +const mockUserRegistrations = [...new Array(5)].map((_, i) => ({ + id: i, + username: `user${i}`, + first_name: `User${i}`, + last_name: `Test${i}`, + email: `user${i}@test.com`, + registration_date: new Date(2025, 2, 25, 11, 4, 32 + i).toISOString(), + registration_hash: `hash${i}`, +})); + +fetchMock.get(userRegistrationsEndpoint, { + ids: [0, 1, 2, 3, 4], + count: 5, + result: mockUserRegistrations, +}); + +describe('UserRegistrations', () => { + beforeEach(() => { + render(, { + useRedux: true, + useRouter: true, + useQueryParams: true, + }); + }); + it('fetches and renders user registrations', async () => { + expect(await screen.findByText('User registrations')).toBeVisible(); + const calls = fetchMock.calls(userRegistrationsEndpoint); + expect(calls.length).toBeGreaterThan(0); + }); +}); diff --git a/superset-frontend/src/pages/UserRegistrations/index.tsx b/superset-frontend/src/pages/UserRegistrations/index.tsx new file mode 100644 index 00000000000..750d1efddc4 --- /dev/null +++ b/superset-frontend/src/pages/UserRegistrations/index.tsx @@ -0,0 +1,238 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useMemo, useState } from 'react'; +import { SupersetClient, t } from '@superset-ui/core'; +import { useListViewResource } from 'src/views/CRUD/hooks'; +import { useToasts } from 'src/components/MessageToasts/withToasts'; +import { + ListViewFilters, + ListViewFilterOperator, + ListView, +} from 'src/components'; +import { DeleteModal } from '@superset-ui/core/components'; +import { ActionProps, ActionsBar } from 'src/components/ListView/ActionsBar'; +import SubMenu from 'src/features/home/SubMenu'; + +const PAGE_SIZE = 25; + +export type UserRegistration = { + id: number; + username: string; + first_name: string; + last_name: string; + email: string; + registration_date: string; + registration_hash: string; +}; + +export default function UserRegistrations() { + const { addSuccessToast, addDangerToast } = useToasts(); + const [ + userRegistrationCurrentlyDeleting, + setUserRegistrationCurrentlyDeleting, + ] = useState(null); + + const { + state: { + loading, + resourceCount: registrationsCount, + resourceCollection: registrations, + }, + refreshData, + fetchData, + } = useListViewResource( + 'security/user_registrations', + t('User Registrations'), + addDangerToast, + ); + + const handleUserRegistrationDelete = async ({ + id, + username, + }: UserRegistration) => { + try { + await SupersetClient.delete({ + endpoint: `/api/v1/security/user_registrations/${id}`, + }); + refreshData(); + setUserRegistrationCurrentlyDeleting(null); + addSuccessToast(t('Deleted user registration for user: %s', username)); + } catch (error) { + addDangerToast( + t('There was an issue deleting registration for user: %s', username), + ); + } + }; + + const initialSort = [{ id: 'registration_date', desc: true }]; + + const columns = useMemo( + () => [ + { + accessor: 'username', + id: 'username', + Header: t('Username'), + Cell: ({ row: { original } }: any) => original.username, + }, + { + accessor: 'first_name', + id: 'first_name', + Header: t('First name'), + Cell: ({ row: { original } }: any) => original.first_name, + }, + { + accessor: 'last_name', + id: 'last_name', + Header: t('Last name'), + Cell: ({ row: { original } }: any) => original.last_name, + }, + { + accessor: 'email', + id: 'email', + Header: t('Email'), + Cell: ({ row: { original } }: any) => original.email, + }, + { + accessor: 'registration_hash', + id: 'registration_hash', + Header: t('Registration hash'), + Cell: ({ row: { original } }: any) => original.registration_hash, + }, + { + accessor: 'registration_date', + id: 'registration_date', + Header: t('Registration date'), + Cell: ({ row: { original } }: any) => original.registration_date, + }, + { + id: 'actions', + Header: t('Actions'), + Cell: ({ row: { original } }: any) => { + const actions = [ + { + label: 'registrations-list-delete-action', + tooltip: t('Delete user registration'), + placement: 'bottom', + icon: 'DeleteOutlined', + onClick: () => { + setUserRegistrationCurrentlyDeleting(original); + }, + }, + ]; + return ; + }, + hidden: false, + disableSortBy: true, + size: 'xl', + }, + ], + [], + ); + + const filters: ListViewFilters = useMemo( + () => [ + { + Header: t('Username'), + key: 'username', + id: 'username', + input: 'search', + operator: ListViewFilterOperator.Contains, + }, + { + Header: t('First name'), + key: 'first_name', + id: 'first_name', + input: 'search', + operator: ListViewFilterOperator.Contains, + }, + { + Header: t('Last name'), + key: 'last_name', + id: 'last_name', + input: 'search', + operator: ListViewFilterOperator.Contains, + }, + { + Header: t('Email'), + key: 'email', + id: 'email', + input: 'search', + operator: ListViewFilterOperator.Contains, + }, + { + Header: t('Registration hash'), + key: 'registration_hash', + id: 'registration_hash', + input: 'search', + operator: ListViewFilterOperator.Contains, + }, + { + Header: t('Registration date'), + key: 'registration_date', + id: 'registration_date', + input: 'datetime_range', + operator: ListViewFilterOperator.Between, + dateFilterValueType: 'iso', + }, + ], + [], + ); + + const emptyState = { + title: t('No user registrations yet'), + image: 'filter-results.svg', + }; + + return ( + <> + + {userRegistrationCurrentlyDeleting && ( + { + if (userRegistrationCurrentlyDeleting) { + handleUserRegistrationDelete(userRegistrationCurrentlyDeleting); + } + }} + onHide={() => setUserRegistrationCurrentlyDeleting(null)} + open + title={t('Delete user registration?')} + /> + )} + + className="user-registrations-list-view" + columns={columns} + count={registrationsCount} + data={registrations} + fetchData={fetchData} + refreshData={refreshData} + addDangerToast={addDangerToast} + addSuccessToast={addSuccessToast} + filters={filters} + initialSort={initialSort} + loading={loading} + pageSize={PAGE_SIZE} + emptyState={emptyState} + /> + + ); +} diff --git a/superset-frontend/src/views/routes.tsx b/superset-frontend/src/views/routes.tsx index 64a86b55a41..332aa0caf10 100644 --- a/superset-frontend/src/views/routes.tsx +++ b/superset-frontend/src/views/routes.tsx @@ -156,6 +156,13 @@ const Register = lazy( const GroupsList: LazyExoticComponent = lazy( () => import(/* webpackChunkName: "GroupsList" */ 'src/pages/GroupsList'), ); +const UserRegistrations = lazy( + () => + import( + /* webpackChunkName: "UserRegistrations" */ 'src/pages/UserRegistrations' + ), +); + type Routes = { path: string; Component: ComponentType; @@ -168,6 +175,10 @@ export const routes: Routes = [ path: '/login/', Component: Login, }, + { + path: '/register/activation/:activationHash', + Component: Register, + }, { path: '/register/', Component: Register, @@ -275,6 +286,10 @@ export const routes: Routes = [ path: '/actionlog/list', Component: ActionLogList, }, + { + path: '/registrations/', + Component: UserRegistrations, + }, ]; if (isFeatureEnabled(FeatureFlag.TaggingSystem)) { @@ -289,6 +304,8 @@ if (isFeatureEnabled(FeatureFlag.TaggingSystem)) { } const user = getBootstrapData()?.user; +const authRegistrationEnabled = + getBootstrapData()?.common.conf.AUTH_USER_REGISTRATION; const isAdmin = isUserAdmin(user); if (isAdmin) { @@ -308,6 +325,13 @@ if (isAdmin) { ); } +if (authRegistrationEnabled) { + routes.push({ + path: '/registrations/', + Component: UserRegistrations, + }); +} + const frontEndRoutes: Record = routes .map(r => r.path) .reduce( diff --git a/superset/initialization/__init__.py b/superset/initialization/__init__.py index 4b0c910c709..dcce3d8f4cf 100644 --- a/superset/initialization/__init__.py +++ b/superset/initialization/__init__.py @@ -150,7 +150,11 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods from superset.reports.api import ReportScheduleRestApi from superset.reports.logs.api import ReportExecutionLogRestApi from superset.row_level_security.api import RLSRestApi - from superset.security.api import RoleRestAPI, SecurityRestApi + from superset.security.api import ( + RoleRestAPI, + SecurityRestApi, + UserRegistrationsRestAPI, + ) from superset.sqllab.api import SqlLabRestApi from superset.sqllab.permalink.api import SqlLabPermalinkRestApi from superset.tags.api import TagRestApi @@ -186,6 +190,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods from superset.views.sqllab import SqllabView from superset.views.tags import TagModelView, TagView from superset.views.user_info import UserInfoView + from superset.views.user_registrations import UserRegistrationsView from superset.views.users.api import CurrentUserRestApi, UserRestApi from superset.views.users_list import UsersListView @@ -285,17 +290,18 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods ) appbuilder.add_view( - UsersListView, - "List Users", - label=__("List Users"), + UserRegistrationsView, + "User Registrations", + label=__("User Registrations"), category="Security", category_label=__("Security"), + menu_cond=lambda: bool(appbuilder.app.config["AUTH_USER_REGISTRATION"]), ) appbuilder.add_view( - ActionLogView, - "Action Logs", - label=__("Action Logs"), + UsersListView, + "List Users", + label=__("List Users"), category="Security", category_label=__("Security"), ) @@ -389,6 +395,20 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods category="Manage", menu_cond=lambda: feature_flag_manager.is_feature_enabled("TAGGING_SYSTEM"), ) + appbuilder.add_api(LogRestApi) + appbuilder.add_api(UserRegistrationsRestAPI) + appbuilder.add_view( + ActionLogView, + "Action Log", + label=__("Action Log"), + category="Security", + category_label=__("Security"), + icon="fa-list-ol", + menu_cond=lambda: ( + self.config["FAB_ADD_SECURITY_VIEWS"] + and self.config["SUPERSET_LOG_VIEW"] + ), + ) appbuilder.add_api(SecurityRestApi) # # Conditionally setup email views diff --git a/superset/security/api.py b/superset/security/api.py index c0890da3ccd..3f291968af9 100644 --- a/superset/security/api.py +++ b/superset/security/api.py @@ -19,10 +19,10 @@ from typing import Any from flask import current_app, request, Response from flask_appbuilder import expose -from flask_appbuilder.api import rison, safe +from flask_appbuilder.api import rison, safe, SQLAInterface from flask_appbuilder.api.schemas import get_list_schema from flask_appbuilder.security.decorators import permission_name, protect -from flask_appbuilder.security.sqla.models import Role +from flask_appbuilder.security.sqla.models import RegisterUser, Role from flask_wtf.csrf import generate_csrf from marshmallow import EXCLUDE, fields, post_load, Schema, ValidationError from sqlalchemy import asc, desc @@ -35,7 +35,11 @@ from superset.commands.exceptions import ForbiddenError from superset.exceptions import SupersetGenericErrorException from superset.extensions import db, event_logger from superset.security.guest_token import GuestTokenResourceType -from superset.views.base_api import BaseSupersetApi, statsd_metrics +from superset.views.base_api import ( + BaseSupersetApi, + BaseSupersetModelRestApi, + statsd_metrics, +) logger = logging.getLogger(__name__) @@ -337,3 +341,22 @@ class RoleRestAPI(BaseSupersetApi): return self.response_403(message=str(e)) except Exception as e: return self.response_500(message=str(e)) + + +class UserRegistrationsRestAPI(BaseSupersetModelRestApi): + """ + APIs for listing user registrations (Admin only) + """ + + resource_name = "security/user_registrations" + datamodel = SQLAInterface(RegisterUser) + allow_browser_login = True + list_columns = [ + "id", + "username", + "email", + "first_name", + "last_name", + "registration_date", + "registration_hash", + ] diff --git a/superset/security/manager.py b/superset/security/manager.py index 2e1fd58f3ad..0db217b30dd 100644 --- a/superset/security/manager.py +++ b/superset/security/manager.py @@ -2798,7 +2798,7 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods for view in list(self.appbuilder.baseviews): if isinstance(view, self.rolemodelview.__class__) and getattr( view, "route_base", None - ) in ["/roles", "/users", "/groups"]: + ) in ["/roles", "/users", "/groups", "registrations"]: self.appbuilder.baseviews.remove(view) security_menu = next( @@ -2806,5 +2806,10 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods ) if security_menu: for item in list(security_menu.childs): - if item.name in ["List Roles", "List Users", "List Groups"]: + if item.name in [ + "List Roles", + "List Users", + "List Groups", + "User Registrations", + ]: security_menu.childs.remove(item) diff --git a/superset/views/auth.py b/superset/views/auth.py index 97cce5b95ad..c24e0ae3622 100644 --- a/superset/views/auth.py +++ b/superset/views/auth.py @@ -15,15 +15,21 @@ # specific language governing permissions and limitations # under the License. +import logging from typing import Optional -from flask import g, redirect +from flask import flash, g, redirect from flask_appbuilder import expose +from flask_appbuilder._compat import as_unicode +from flask_appbuilder.const import LOGMSG_ERR_SEC_NO_REGISTER_HASH from flask_appbuilder.security.decorators import no_cache from flask_appbuilder.security.views import AuthView, WerkzeugResponse +from flask_babel import lazy_gettext from superset.views.base import BaseSupersetView +logger = logging.getLogger(__name__) + class SupersetAuthView(BaseSupersetView, AuthView): route_base = "/login" @@ -39,8 +45,47 @@ class SupersetAuthView(BaseSupersetView, AuthView): class SupersetRegisterUserView(BaseSupersetView): route_base = "/register" + activation_template = "" + error_message = lazy_gettext( + "Not possible to register you at the moment, try again later" + ) + false_error_message = lazy_gettext("Registration not found") @expose("/") @no_cache def register(self) -> WerkzeugResponse: return super().render_app_template() + + @expose("/activation/") + def activation(self, activation_hash: str) -> WerkzeugResponse: + """ + Endpoint to expose an activation url, this url + is sent to the user by email, when accessed the user is inserted + and activated + """ + reg = self.appbuilder.sm.find_register_user(activation_hash) + if not reg: + logger.error(LOGMSG_ERR_SEC_NO_REGISTER_HASH, activation_hash) + flash(as_unicode(self.false_error_message), "danger") + return redirect(self.appbuilder.get_url_for_index) + if not self.appbuilder.sm.add_user( + username=reg.username, + email=reg.email, + first_name=reg.first_name, + last_name=reg.last_name, + role=self.appbuilder.sm.find_role( + self.appbuilder.sm.auth_user_registration_role + ), + hashed_password=reg.password, + ): + flash(as_unicode(self.error_message), "danger") + return redirect(self.appbuilder.get_url_for_index) + else: + self.appbuilder.sm.del_register_user(reg) + return super().render_app_template( + { + "username": reg.username, + "first_name": reg.first_name, + "last_name": reg.last_name, + }, + ) diff --git a/superset/views/user_registrations.py b/superset/views/user_registrations.py new file mode 100644 index 00000000000..10c8fab2dcf --- /dev/null +++ b/superset/views/user_registrations.py @@ -0,0 +1,34 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +from flask_appbuilder import permission_name +from flask_appbuilder.api import expose +from flask_appbuilder.security.decorators import has_access + +from superset.superset_typing import FlaskResponse + +from .base import BaseSupersetView + + +class UserRegistrationsView(BaseSupersetView): + route_base = "/" + class_permission_name = "security" + + @expose("/registrations/") + @has_access + @permission_name("read") + def list(self) -> FlaskResponse: + return super().render_app_template() diff --git a/tests/integration_tests/security_tests.py b/tests/integration_tests/security_tests.py index 7f2c370233f..7528fcb620b 100644 --- a/tests/integration_tests/security_tests.py +++ b/tests/integration_tests/security_tests.py @@ -1556,6 +1556,7 @@ class TestRolePermission(SupersetTestCase): ["SupersetAuthView", "login"], ["SupersetAuthView", "logout"], ["SupersetRegisterUserView", "register"], + ["SupersetRegisterUserView", "activation"], ] unsecured_views = [] for view_class in appbuilder.baseviews: