diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index 0423016..429413a 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -47,7 +47,7 @@ const Navbar = () => { const userEmailKey = 'user-email' const userLogoutKey = 'user-logout' const userDividerKey = 'user-divider' - const adminOnlyTabs = ["/setup-keys", "/acls", "/routes", "/dns", "/activity", "/settings"] + const adminOnlyTabs = ["/setup-keys", "/acls", "/routes", "/dns", "/activity"] const [menuItems, setMenuItems] = useState(items) const logoutWithRedirect = () => logout("/", {client_id: config.clientId}); diff --git a/src/store/index.ts b/src/store/index.ts index 085e421..24fc5b8 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -12,6 +12,7 @@ import { sagas as nameserverGroupSagas } from './nameservers'; import { sagas as eventSagas } from './event'; import { sagas as dnsSettingsSagas } from './dns-settings'; import { sagas as accountSagas } from './account'; +import { sagas as personalAccessTokenSagas } from './personal-access-token'; import rootReducer from './root-reducer'; import { apiClient } from '../services/api-client'; @@ -33,5 +34,6 @@ sagaMiddleware.run(nameserverGroupSagas); sagaMiddleware.run(eventSagas); sagaMiddleware.run(dnsSettingsSagas); sagaMiddleware.run(accountSagas); +sagaMiddleware.run(personalAccessTokenSagas); export { apiClient, rootReducer, store }; \ No newline at end of file diff --git a/src/store/personal-access-token/actions.ts b/src/store/personal-access-token/actions.ts new file mode 100644 index 0000000..08c1bc4 --- /dev/null +++ b/src/store/personal-access-token/actions.ts @@ -0,0 +1,39 @@ +import { ActionType, createAction, createAsyncAction } from 'typesafe-actions'; +import {PersonalAccessToken, PersonalAccessTokenCreate, PersonalAccessTokenGenerated, SpecificPAT} from './types'; +import { + ApiError, + CreateResponse, + DeleteResponse, + RequestPayload +} from '../../services/api-client/types'; + +const actions = { + getPersonalAccessTokens: createAsyncAction( + 'GET_PERSONAL_ACCESS_TOKEN_REQUEST', + 'GET_PERSONAL_ACCESS_TOKEN_SUCCESS', + 'GET_PERSONAL_ACCESS_TOKEN_FAILURE', + ), PersonalAccessToken[], ApiError>(), + + savePersonalAccessToken: createAsyncAction( + 'SAVE_PERSONAL_ACCESS_TOKEN_REQUEST', + 'SAVE_PERSONAL_ACCESS_TOKEN_SUCCESS', + 'SAVE_PERSONAL_ACCESS_TOKEN_FAILURE', + ), CreateResponse, CreateResponse>(), + setSavedPersonalAccessToken: createAction('SET_PERSONAL_ACCESS_TOKEN_KEY')>(), + resetSavedPersonalAccessToken: createAction('RESET_PERSONAL_ACCESS_TOKEN_KEY')(), + + deletePersonalAccessToken: createAsyncAction( + 'DELETE_PERSONAL_ACCESS_TOKEN_REQUEST', + 'DELETE_PERSONAL_ACCESS_TOKEN_SUCCESS', + 'DELETE_PERSONAL_ACCESS_TOKEN_FAILURE' + ), DeleteResponse, DeleteResponse>(), + setDeletePersonalAccessToken: createAction('SET_DELETE_PERSONAL_ACCESS_TOKEN')>(), + resetDeletedPersonalAccessToken: createAction('RESET_DELETE_PERSONAL_ACCESS_TOKEN')(), + + removePersonalAccessToken: createAction('REMOVE_PERSONAL_ACCESS_TOKEN')(), + setPersonalAccessToken: createAction('SET_SETUP_KEY')(), + setNewPersonalAccessTokenVisible: createAction('SET_NEW_PERSONAL_ACCESS_TOKEN_VISIBLE')() +}; + +export type ActionTypes = ActionType; +export default actions; diff --git a/src/store/personal-access-token/index.ts b/src/store/personal-access-token/index.ts new file mode 100644 index 0000000..1f10d21 --- /dev/null +++ b/src/store/personal-access-token/index.ts @@ -0,0 +1,7 @@ +import actions, { ActionTypes as _actionTypes } from './actions'; +import reducer from './reducer'; +import sagas from './sagas'; + +export type ActionTypes = _actionTypes; + +export { actions, reducer, sagas }; diff --git a/src/store/personal-access-token/reducer.ts b/src/store/personal-access-token/reducer.ts new file mode 100644 index 0000000..7235841 --- /dev/null +++ b/src/store/personal-access-token/reducer.ts @@ -0,0 +1,97 @@ +import { createReducer } from 'typesafe-actions'; +import { combineReducers } from 'redux'; +import actions, { ActionTypes } from './actions'; +import {ApiError, DeleteResponse, CreateResponse, ChangeResponse} from "../../services/api-client/types"; +import {PersonalAccessToken, PersonalAccessTokenCreate, PersonalAccessTokenGenerated} from "./types"; + +type StateType = Readonly<{ + data: PersonalAccessToken[] | null; + personalAccessToken: PersonalAccessTokenCreate | null; + loading: boolean; + failed: ApiError | null; + saving: boolean; + deletedPersonalAccessToken: DeleteResponse; + revokedPersonalAccessToken: ChangeResponse; + savedPersonalAccessToken: CreateResponse; + newPersonalAccessTokenVisible: boolean +}>; + +const initialState: StateType = { + data: [], + personalAccessToken: null, + loading: false, + failed: null, + saving: false, + deletedPersonalAccessToken: >{ + loading: false, + success: false, + failure: false, + error: null, + data : null + }, + revokedPersonalAccessToken: >{ + loading: false, + success: false, + failure: false, + error: null, + data : null + }, + savedPersonalAccessToken: >{ + loading: false, + success: false, + failure: false, + error: null, + data : null + }, + newPersonalAccessTokenVisible: false +}; + +const data = createReducer(initialState.data as PersonalAccessToken[]) + .handleAction(actions.getPersonalAccessTokens.success,(_, action) => action.payload) + .handleAction(actions.getPersonalAccessTokens.failure, () => []); + +const personalAccessToken = createReducer(initialState.personalAccessToken as PersonalAccessTokenCreate) + .handleAction(actions.setPersonalAccessToken, (store, action) => action.payload); + +const loading = createReducer(initialState.loading) + .handleAction(actions.getPersonalAccessTokens.request, () => true) + .handleAction(actions.getPersonalAccessTokens.success, () => false) + .handleAction(actions.getPersonalAccessTokens.failure, () => false); + +const failed = createReducer(initialState.failed) + .handleAction(actions.getPersonalAccessTokens.request, () => null) + .handleAction(actions.getPersonalAccessTokens.success, () => null) + .handleAction(actions.getPersonalAccessTokens.failure, (store, action) => action.payload); + +const saving = createReducer(initialState.saving) + .handleAction(actions.getPersonalAccessTokens.request, () => true) + .handleAction(actions.getPersonalAccessTokens.success, () => false) + .handleAction(actions.getPersonalAccessTokens.failure, () => false); + +const deletedPersonalAccessToken = createReducer, ActionTypes>(initialState.deletedPersonalAccessToken) + .handleAction(actions.deletePersonalAccessToken.request, () => initialState.deletedPersonalAccessToken) + .handleAction(actions.deletePersonalAccessToken.success, (store, action) => action.payload) + .handleAction(actions.deletePersonalAccessToken.failure, (store, action) => action.payload) + .handleAction(actions.setDeletePersonalAccessToken, (store, action) => action.payload) + .handleAction(actions.resetDeletedPersonalAccessToken, (store, action) => initialState.deletedPersonalAccessToken); + +const savedPersonalAccessToken = createReducer, ActionTypes>(initialState.savedPersonalAccessToken) + .handleAction(actions.savePersonalAccessToken.request, () => initialState.savedPersonalAccessToken) + .handleAction(actions.savePersonalAccessToken.success, (store, action) => action.payload) + .handleAction(actions.savePersonalAccessToken.failure, (store, action) => action.payload) + .handleAction(actions.setSavedPersonalAccessToken, (store, action) => action.payload) + .handleAction(actions.resetSavedPersonalAccessToken, () => initialState.savedPersonalAccessToken) + +const newPersonalAccessTokenVisible = createReducer(initialState.newPersonalAccessTokenVisible) + .handleAction(actions.setNewPersonalAccessTokenVisible, (store, action) => action.payload) + +export default combineReducers({ + data, + personalAccessToken: personalAccessToken, + loading, + failed, + saving, + deletedPersonalAccessToken: deletedPersonalAccessToken, + savedPersonalAccessToken: savedPersonalAccessToken, + newPersonalAccessTokenVisible: newPersonalAccessTokenVisible +}); diff --git a/src/store/personal-access-token/sagas.ts b/src/store/personal-access-token/sagas.ts new file mode 100644 index 0000000..eca784a --- /dev/null +++ b/src/store/personal-access-token/sagas.ts @@ -0,0 +1,101 @@ +import {all, call, put, select, takeLatest} from 'redux-saga/effects'; +import {ApiError, ApiResponse, CreateResponse, DeleteResponse} from '../../services/api-client/types'; +import {PersonalAccessToken, PersonalAccessTokenCreate, PersonalAccessTokenGenerated} from './types' +import service from './service'; +import actions from './actions'; + +export function* getPersonalAccessTokens(action: ReturnType): Generator { + try { + const effect = yield call(service.getAllPersonalAccessTokens, action.payload); + const response = effect as ApiResponse; + + yield put(actions.getPersonalAccessTokens.success(response.body)); + } catch (err) { + yield put(actions.getPersonalAccessTokens.failure(err as ApiError)); + } +} + +export function* savePersonalAccessToken(action: ReturnType): Generator { + try { + yield put(actions.setSavedPersonalAccessToken({ + loading: true, + success: false, + failure: false, + error: null, + data: null + } as CreateResponse)) + + const tokenToSave = action.payload.payload + + let effect = yield call(service.createPersonalAccessToken, { + getAccessTokenSilently: action.payload.getAccessTokenSilently, + payload: { + user_id: tokenToSave.user_id, + name: tokenToSave.name, + expires_in: tokenToSave.expires_in, + } as PersonalAccessTokenCreate + }); + const response = effect as ApiResponse; + + yield put(actions.savePersonalAccessToken.success({ + loading: false, + success: true, + failure: false, + error: null, + data: response.body + } as CreateResponse)); + + yield put(actions.getPersonalAccessTokens.request({ getAccessTokenSilently: action.payload.getAccessTokenSilently, payload: tokenToSave.user_id })); + } catch (err) { + yield put(actions.savePersonalAccessToken.failure({ + loading: false, + success: false, + failure: false, + error: err as ApiError, + data: null + } as CreateResponse)); + } +} + +export function* deletePersonalAccessToken(action: ReturnType): Generator { + try { + yield call(actions.setDeletePersonalAccessToken,{ + loading: true, + success: false, + failure: false, + error: null, + data: null + } as DeleteResponse) + + const effect = yield call(service.deletePersonalAccessToken, action.payload); + const response = effect as ApiResponse; + + yield put(actions.deletePersonalAccessToken.success({ + loading: false, + success: true, + failure: false, + error: null, + data: response.body + } as DeleteResponse)); + + const personalAccessTokens = (yield select(state => state.personalAccessToken.data)) as PersonalAccessToken[] + yield put(actions.getPersonalAccessTokens.success(personalAccessTokens.filter((p:PersonalAccessToken) => p.id !== action.payload.payload.id))) + } catch (err) { + yield put(actions.deletePersonalAccessToken.failure({ + loading: false, + success: false, + failure: false, + error: err as ApiError, + data: null + } as DeleteResponse)); + } +} + +export default function* sagas(): Generator { + yield all([ + takeLatest(actions.getPersonalAccessTokens.request, getPersonalAccessTokens), + takeLatest(actions.savePersonalAccessToken.request, savePersonalAccessToken), + takeLatest(actions.deletePersonalAccessToken.request, deletePersonalAccessToken) + ]); +} + diff --git a/src/store/personal-access-token/service.ts b/src/store/personal-access-token/service.ts new file mode 100644 index 0000000..d00dd5e --- /dev/null +++ b/src/store/personal-access-token/service.ts @@ -0,0 +1,34 @@ +import {ApiResponse, RequestPayload} from '../../services/api-client/types'; +import { apiClient } from '../../services/api-client'; +import { + PersonalAccessToken, + PersonalAccessTokenCreate, PersonalAccessTokenGenerated, + SpecificPAT +} from './types'; + +export default { + async getAllPersonalAccessTokens(payload:RequestPayload): Promise> { + return apiClient.get( + `/api/users/` + payload.payload + `/tokens`, + payload + ); + }, + async getPersonalAccessToken(payload:RequestPayload): Promise> { + return apiClient.get( + `/api/users/` + payload.payload.user_id + `/tokens/` + payload.payload.id, + payload + ); + }, + async deletePersonalAccessToken(payload:RequestPayload): Promise> { + return apiClient.delete( + `/api/users/` + payload.payload.user_id + `/tokens/` + payload.payload.id, + payload + ); + }, + async createPersonalAccessToken(payload:RequestPayload): Promise> { + return apiClient.post( + `/api/users/` + payload.payload.user_id + `/tokens`, + payload + ); + }, +}; diff --git a/src/store/personal-access-token/types.ts b/src/store/personal-access-token/types.ts new file mode 100644 index 0000000..a5f089f --- /dev/null +++ b/src/store/personal-access-token/types.ts @@ -0,0 +1,26 @@ + +export interface PersonalAccessToken { + id: string; + name: string; + expiration_date: string; + created_by: string; + created_at: string; + last_used: string; +} + +export interface SpecificPAT { + name: string, + user_id: string, + id: string, +} + +export interface PersonalAccessTokenGenerated { + plain_token: string, + personal_access_token: PersonalAccessToken +} + +export interface PersonalAccessTokenCreate { + user_id: string, + name: string, + expires_in: number, +} diff --git a/src/store/root-action.ts b/src/store/root-action.ts index 814fde8..e6712cf 100644 --- a/src/store/root-action.ts +++ b/src/store/root-action.ts @@ -8,6 +8,7 @@ import {actions as NameServerGroupActions} from './nameservers'; import {actions as EventActions} from './event'; import {actions as DNSSettingsActions} from './dns-settings'; import {actions as AccountActions} from './account'; +import {actions as PersonalAccessTokenActions} from './personal-access-token'; export default { peer: PeerActions, @@ -19,5 +20,6 @@ export default { nameserverGroup: NameServerGroupActions, event: EventActions, dnsSettings: DNSSettingsActions, - account: AccountActions + account: AccountActions, + personalAccessToken: PersonalAccessTokenActions }; diff --git a/src/store/root-reducer.ts b/src/store/root-reducer.ts index 74e62f0..01d8f50 100644 --- a/src/store/root-reducer.ts +++ b/src/store/root-reducer.ts @@ -10,6 +10,7 @@ import { reducer as nameserverGroup } from './nameservers'; import { reducer as event } from './event'; import { reducer as dnsSettings } from './dns-settings'; import { reducer as account } from './account'; +import { reducer as personalAccessToken } from './personal-access-token'; export default combineReducers({ peer, @@ -21,5 +22,6 @@ export default combineReducers({ nameserverGroup, event, dnsSettings, - account + account, + personalAccessToken }); diff --git a/src/utils/common.js b/src/utils/common.js index 2146547..e1f9915 100644 --- a/src/utils/common.js +++ b/src/utils/common.js @@ -93,7 +93,9 @@ export const timeAgo = (dateParam) => { const isThisYear = today.getFullYear() === date.getFullYear(); - if (seconds < 5) { + if (seconds < -1) { + return getFormattedDate(date, false, true); + } else if (seconds < 5) { return 'just now'; } else if (seconds < 60) { return `${ seconds } seconds ago`; @@ -102,9 +104,9 @@ export const timeAgo = (dateParam) => { } else if (minutes < 60) { return `${ minutes } minutes ago`; } else if (isToday) { - return getFormattedDate(date, 'Today'); // Today at 10:20 + return getFormattedDate(date, 'today'); // Today at 10:20 } else if (isYesterday) { - return getFormattedDate(date, 'Yesterday'); // Yesterday at 10:20 + return getFormattedDate(date, 'yesterday'); // Yesterday at 10:20 } else if (isThisYear) { return getFormattedDate(date, false, true); // 10. January at 10:20 } diff --git a/src/views/AccessControl.tsx b/src/views/AccessControl.tsx index 8c75879..5050a92 100644 --- a/src/views/AccessControl.tsx +++ b/src/views/AccessControl.tsx @@ -197,16 +197,13 @@ export const AccessControl = () => { } const showConfirmDelete = () => { + let name = ruleToAction ? ruleToAction.name : ''; confirm({ icon: , + title: "Delete rule \"" + name + "\"", width: 600, content: - {ruleToAction && - <> - Delete rule "{ruleToAction ? ruleToAction.name : ''}" - Are you sure you want to delete this rule from your account? - - } + Are you sure you want to delete this rule from your account? , okType: 'danger', onOk() { diff --git a/src/views/Activity.tsx b/src/views/Activity.tsx index 0150906..ad711b4 100644 --- a/src/views/Activity.tsx +++ b/src/views/Activity.tsx @@ -194,6 +194,12 @@ export const Activity = () => { case "account.setting.peer.login.expiration.disable": case "account.setting.peer.login.expiration.update": return renderMultiRowSpan("","System setting") + case "personal.access.token.create": + case "personal.access.token.delete": + if(user) { + return renderMultiRowSpan(event.meta.name, user.name ? user.name : event.target_id) + } + return "-" case "user.invite": if (user) { return renderMultiRowSpan(user.name ? user.name : user.id,user.email ? user.email : "User") @@ -250,7 +256,7 @@ export const Activity = () => { pagination={{ pageSize, showSizeChanger: false, - showTotal: ((total, range) => `Showing ${range[0]} to ${range[1]} of ${total} users`) + showTotal: ((total, range) => `Showing ${range[0]} to ${range[1]} of ${total} activity events`) }} className="card-table" showSorterTooltip={false} diff --git a/src/views/Nameservers.tsx b/src/views/Nameservers.tsx index edd5ed5..3798309 100644 --- a/src/views/Nameservers.tsx +++ b/src/views/Nameservers.tsx @@ -153,16 +153,13 @@ export const Nameservers = () => { } const showConfirmDelete = () => { + let name = nsGroupToAction ? nsGroupToAction.name : ''; confirm({ icon: , + title: "Delete Nameserver group \"" + name + "\"", width: 600, content: - {nsGroupToAction && - <> - Delete Nameserver group "{nsGroupToAction ? nsGroupToAction.name : ''}" - Are you sure you want to delete this nameserver group from your account? - - } + Are you sure you want to delete this nameserver group from your account? , okType: 'danger', onOk() { @@ -386,7 +383,7 @@ export const Nameservers = () => { pagination={{ pageSize, showSizeChanger: false, - showTotal: ((total, range) => `Showing ${range[0]} to ${range[1]} of ${total} users`) + showTotal: ((total, range) => `Showing ${range[0]} to ${range[1]} of ${total} nameservers`) }} // className="card-table" className={`access-control-table ${showTutorial ? "card-table card-table-no-placeholder" : "card-table"}`} diff --git a/src/views/Routes.tsx b/src/views/Routes.tsx index d415bbe..117bee1 100644 --- a/src/views/Routes.tsx +++ b/src/views/Routes.tsx @@ -206,16 +206,13 @@ export const Routes = () => { } const showConfirmDelete = () => { + let name = routeToAction ? routeToAction.network_id : ''; confirm({ icon: , + title: "Delete network route \"" + name + "\"", width: 600, content: - {routeToAction && - <> - Delete netowork route "{routeToAction ? routeToAction.network_id : ''}" - Are you sure you want to delete this route from your account? - - } + Are you sure you want to delete this route from your account? , okType: 'danger', onOk() { diff --git a/src/views/Settings.tsx b/src/views/Settings.tsx index 72c2f77..ffff79d 100644 --- a/src/views/Settings.tsx +++ b/src/views/Settings.tsx @@ -1,16 +1,14 @@ import React, {useEffect, useState} from 'react'; + +import {Button, Card, Col, Form, List, message, Modal, Radio, Row, Space, Tabs, TabsProps, Typography,} from "antd"; +import {Container} from "../components/Container"; +import SettingsPersonal from "./SettingsPersonal"; +import SettingsAccount from "./SettingsAccount"; +import {useOidcUser} from "@axa-fr/react-oidc"; +import {actions as userActions} from "../store/user"; +import {useGetTokenSilently} from "../utils/token"; import {useDispatch, useSelector} from "react-redux"; import {RootState} from "typesafe-actions"; -import {Button, Card, Col, Form, List, message, Modal, Radio, Row, Space, Typography,} from "antd"; -import {useGetTokenSilently} from "../utils/token"; -import {useGetGroupTagHelpers} from "../utils/groups"; -import {Container} from "../components/Container"; -import UserUpdate from "../components/UserUpdate"; -import ExpiresInInput, {expiresInToSeconds, secondsToExpiresIn} from "./ExpiresInInput"; -import {checkExpiresIn} from "../utils/common"; -import {actions as accountActions} from "../store/account"; -import {Account, FormAccount} from "../store/account/types"; -import {ExclamationCircleOutlined, QuestionCircleFilled} from "@ant-design/icons"; const {Title, Paragraph} = Typography; @@ -20,134 +18,50 @@ export const Settings = () => { const {getTokenSilently} = useGetTokenSilently() const dispatch = useDispatch() - const { - } = useGetGroupTagHelpers() + const {oidcUser} = useOidcUser(); + const [isAdmin, setIsAdmin] = useState(false); + const users = useSelector((state: RootState) => state.user.data) - const accounts = useSelector((state: RootState) => state.account.data); - const failed = useSelector((state: RootState) => state.account.failed); - const loading = useSelector((state: RootState) => state.account.loading); - const updatedAccount = useSelector((state: RootState) => state.account.updatedAccount); - const users = useSelector((state: RootState) => state.user.data); - const [formAccount, setFormAccount] = useState({} as FormAccount); - const [accountToAction, setAccountToAction] = useState({} as FormAccount); - const [formPeerExpirationEnabled, setFormPeerExpirationEnabled] = useState(true); - const [confirmModal, confirmModalContextHolder] = Modal.useModal(); + const nsTabKey = '1' + const userItems: TabsProps['items'] = [ + { + key: nsTabKey, + label: 'Personal Settings', + children: , + }, + ] - const [form] = Form.useForm() + const adminOnlyItems: TabsProps['items'] = [ + { + key: '2', + label: 'Account Settings', + children: , + }, + ] + + const [tabItems, setTabItems] = useState(userItems); useEffect(() => { - dispatch(accountActions.getAccounts.request({getAccessTokenSilently: getTokenSilently, payload: null})); + if (isAdmin) { + setTabItems(userItems.concat(adminOnlyItems)); + } + }, [isAdmin]) + + useEffect(() => { + if(users && oidcUser) { + let currentUser = users.find((user) => user.id === oidcUser.sub) + if(currentUser) { + setIsAdmin(currentUser.role === 'admin'); + } + } + }, [users, oidcUser]) + + useEffect(() => { + dispatch(userActions.getUsers.request({getAccessTokenSilently: getTokenSilently, payload: null})) }, []) - useEffect(() => { - if (accounts.length < 1) { - console.error("invalid account data returned from the Management API", accounts) - return - } - let account = accounts[0] + const onTabClick = (key:string) => { - let fAccount = { - id: account.id, - settings: account.settings, - peer_login_expiration_formatted: secondsToExpiresIn(account.settings.peer_login_expiration, ["hour", "day"]), - peer_login_expiration_enabled: account.settings.peer_login_expiration_enabled - } as FormAccount - setFormAccount(fAccount) - setFormPeerExpirationEnabled(fAccount.peer_login_expiration_enabled) - form.setFieldsValue(fAccount) - }, [accounts]) - - const updatingSettings = 'updating_settings'; - useEffect(() => { - if (updatedAccount.loading) { - message.loading({content: 'Saving...', key: updatingSettings, duration: 0, style: styleNotification}); - } else if (updatedAccount.success) { - message.success({ - content: 'Account settings have been successfully saved.', - key: updatingSettings, - duration: 2, - style: styleNotification - }); - dispatch(accountActions.setUpdateAccount({...updatedAccount, success: false})); - dispatch(accountActions.resetUpdateAccount(null)) - let fAccount = { - id: updatedAccount.data.id, - settings: updatedAccount.data.settings, - peer_login_expiration_formatted: secondsToExpiresIn(updatedAccount.data.settings.peer_login_expiration, ["hour", "day"]), - peer_login_expiration_enabled: updatedAccount.data.settings.peer_login_expiration_enabled - } as FormAccount - setFormAccount(fAccount) - } else if (updatedAccount.error) { - let errorMsg = "Failed to update account settings" - switch (updatedAccount.error.statusCode) { - case 403: - errorMsg = "Failed to update account settings. You might not have enough permissions." - break - default: - errorMsg = updatedAccount.error.data.message ? updatedAccount.error.data.message : errorMsg - break - } - message.error({ - content: errorMsg, - key: updatingSettings, - duration: 5, - style: styleNotification - }); - } - }, [updatedAccount]) - - const handleFormSubmit = () => { - form.validateFields() - .then((values) => { - confirmSave(values) - }) - .catch((errorInfo) => { - let msg = "please check the fields and try again" - if (errorInfo.errorFields) { - msg = errorInfo.errorFields[0].errors[0] - } - message.error({ - content: msg, - duration: 1, - }); - }); - } - - const createAccountToSave = (values: FormAccount): Account => { - return { - id: formAccount.id, - settings: { - peer_login_expiration: expiresInToSeconds(values.peer_login_expiration_formatted), - peer_login_expiration_enabled: values.peer_login_expiration_enabled - } - } as Account - } - - const confirmSave = (newValues: FormAccount) => { - if (newValues.peer_login_expiration_enabled != formAccount.peer_login_expiration_enabled) { - let content = newValues.peer_login_expiration_enabled ? "Enabling peer expiration will cause some peers added with the SSO login to disconnect, and re-authentication will be required. Do you want to enable peer login expiration?" : "Disabling peer expiration will cause peers added with the SSO login never to expire. For security reasons, keeping peers expiring periodically is usually better. Do you want to disable peer login expiration?" - confirmModal.confirm({ - icon: , - title: "Before you update your account settings.", - width: 600, - content: content, - onOk() { - saveAccount(newValues) - }, - onCancel() { - }, - }); - } else { - saveAccount(newValues) - } - } - - const saveAccount = (newValues: FormAccount) => { - let accountToSave = createAccountToSave(newValues) - dispatch(accountActions.updateAccount.request({ - getAccessTokenSilently: getTokenSilently, - payload: accountToSave - })) } return ( @@ -155,73 +69,16 @@ export const Settings = () => { - Settings - Manage your account's settings - - - -
- - - - - - - - - - - - - - - - -
-
-
+
- - {confirmModalContextHolder} ) } diff --git a/src/views/SettingsAccount.tsx b/src/views/SettingsAccount.tsx new file mode 100644 index 0000000..932d766 --- /dev/null +++ b/src/views/SettingsAccount.tsx @@ -0,0 +1,229 @@ +import React, {useEffect, useState} from 'react'; +import {useDispatch, useSelector} from "react-redux"; +import {RootState} from "typesafe-actions"; +import {Button, Card, Col, Form, List, message, Modal, Radio, Row, Space, Typography,} from "antd"; +import {useGetTokenSilently} from "../utils/token"; +import {useGetGroupTagHelpers} from "../utils/groups"; +import {Container} from "../components/Container"; +import UserUpdate from "../components/UserUpdate"; +import ExpiresInInput, {expiresInToSeconds, secondsToExpiresIn} from "./ExpiresInInput"; +import {checkExpiresIn} from "../utils/common"; +import {actions as accountActions} from "../store/account"; +import {Account, FormAccount} from "../store/account/types"; +import {ExclamationCircleOutlined, QuestionCircleFilled} from "@ant-design/icons"; + +const {Title, Paragraph} = Typography; + +const styleNotification = {marginTop: 85} + +export const Settings = () => { + const {getTokenSilently} = useGetTokenSilently() + const dispatch = useDispatch() + + const { + } = useGetGroupTagHelpers() + + const accounts = useSelector((state: RootState) => state.account.data); + const failed = useSelector((state: RootState) => state.account.failed); + const loading = useSelector((state: RootState) => state.account.loading); + const updatedAccount = useSelector((state: RootState) => state.account.updatedAccount); + const users = useSelector((state: RootState) => state.user.data); + const [formAccount, setFormAccount] = useState({} as FormAccount); + const [accountToAction, setAccountToAction] = useState({} as FormAccount); + const [formPeerExpirationEnabled, setFormPeerExpirationEnabled] = useState(true); + const [confirmModal, confirmModalContextHolder] = Modal.useModal(); + + const [form] = Form.useForm() + + useEffect(() => { + dispatch(accountActions.getAccounts.request({getAccessTokenSilently: getTokenSilently, payload: null})); + }, []) + + useEffect(() => { + if (accounts.length < 1) { + console.error("invalid account data returned from the Management API", accounts) + return + } + let account = accounts[0] + + let fAccount = { + id: account.id, + settings: account.settings, + peer_login_expiration_formatted: secondsToExpiresIn(account.settings.peer_login_expiration, ["hour", "day"]), + peer_login_expiration_enabled: account.settings.peer_login_expiration_enabled + } as FormAccount + setFormAccount(fAccount) + setFormPeerExpirationEnabled(fAccount.peer_login_expiration_enabled) + form.setFieldsValue(fAccount) + }, [accounts]) + + const updatingSettings = 'updating_settings'; + useEffect(() => { + if (updatedAccount.loading) { + message.loading({content: 'Saving...', key: updatingSettings, duration: 0, style: styleNotification}); + } else if (updatedAccount.success) { + message.success({ + content: 'Account settings have been successfully saved.', + key: updatingSettings, + duration: 2, + style: styleNotification + }); + dispatch(accountActions.setUpdateAccount({...updatedAccount, success: false})); + dispatch(accountActions.resetUpdateAccount(null)) + let fAccount = { + id: updatedAccount.data.id, + settings: updatedAccount.data.settings, + peer_login_expiration_formatted: secondsToExpiresIn(updatedAccount.data.settings.peer_login_expiration, ["hour", "day"]), + peer_login_expiration_enabled: updatedAccount.data.settings.peer_login_expiration_enabled + } as FormAccount + setFormAccount(fAccount) + } else if (updatedAccount.error) { + let errorMsg = "Failed to update account settings" + switch (updatedAccount.error.statusCode) { + case 403: + errorMsg = "Failed to update account settings. You might not have enough permissions." + break + default: + errorMsg = updatedAccount.error.data.message ? updatedAccount.error.data.message : errorMsg + break + } + message.error({ + content: errorMsg, + key: updatingSettings, + duration: 5, + style: styleNotification + }); + } + }, [updatedAccount]) + + const handleFormSubmit = () => { + form.validateFields() + .then((values) => { + confirmSave(values) + }) + .catch((errorInfo) => { + let msg = "please check the fields and try again" + if (errorInfo.errorFields) { + msg = errorInfo.errorFields[0].errors[0] + } + message.error({ + content: msg, + duration: 1, + }); + }); + } + + const createAccountToSave = (values: FormAccount): Account => { + return { + id: formAccount.id, + settings: { + peer_login_expiration: expiresInToSeconds(values.peer_login_expiration_formatted), + peer_login_expiration_enabled: values.peer_login_expiration_enabled + } + } as Account + } + + const confirmSave = (newValues: FormAccount) => { + if (newValues.peer_login_expiration_enabled != formAccount.peer_login_expiration_enabled) { + let content = newValues.peer_login_expiration_enabled ? "Enabling peer expiration will cause some peers added with the SSO login to disconnect, and re-authentication will be required. Do you want to enable peer login expiration?" : "Disabling peer expiration will cause peers added with the SSO login never to expire. For security reasons, keeping peers expiring periodically is usually better. Do you want to disable peer login expiration?" + confirmModal.confirm({ + icon: , + title: "Before you update your account settings.", + width: 600, + content: content, + onOk() { + saveAccount(newValues) + }, + onCancel() { + }, + }); + } else { + saveAccount(newValues) + } + } + + const saveAccount = (newValues: FormAccount) => { + let accountToSave = createAccountToSave(newValues) + dispatch(accountActions.updateAccount.request({ + getAccessTokenSilently: getTokenSilently, + payload: accountToSave + })) + } + + return ( + <> + + + + Settings + Manage your account's settings + + + +
+ + + + + + + + + + + + + + + + +
+
+
+ +
+
+ + {confirmModalContextHolder} + + ) +} + +export default Settings; \ No newline at end of file diff --git a/src/views/SettingsPersonal.tsx b/src/views/SettingsPersonal.tsx new file mode 100644 index 0000000..46afad0 --- /dev/null +++ b/src/views/SettingsPersonal.tsx @@ -0,0 +1,514 @@ +import React, {useEffect, useRef, useState} from 'react'; +import {useDispatch, useSelector} from "react-redux"; +import {RootState} from "typesafe-actions"; +import {actions as personalAccessTokenActions} from '../store/personal-access-token'; +import {actions as userActions} from '../store/user'; +import {Container} from "../components/Container"; +import { + Alert, + Button, + Card, + Col, Divider, + Dropdown, Form, + Input, InputNumber, + Menu, + message, + Modal, + Radio, + RadioChangeEvent, + Row, + Select, + Space, + Table, + Tag, + Typography +} from "antd"; +import {filter, isNil} from "lodash" +import {copyToClipboard, timeAgo} from "../utils/common"; +import {CheckOutlined, CopyOutlined, ExclamationCircleOutlined, QuestionCircleFilled} from "@ant-design/icons"; +import tableSpin from "../components/Spin"; +import {useGetTokenSilently} from "../utils/token"; +import {usePageSizeHelpers} from "../utils/pageSize"; +import {PersonalAccessToken, PersonalAccessTokenCreate, SpecificPAT} from "../store/personal-access-token/types"; +import {User} from "../store/user/types"; +import {useOidcUser} from "@axa-fr/react-oidc"; +import SyntaxHighlighter from "react-syntax-highlighter"; + +const {Title, Text, Paragraph} = Typography; +const {Column} = Table; +const {confirm} = Modal; + +const ExpiresInDefault = 7 + +interface TokenDataTable extends PersonalAccessToken { + key: string + status: String +} + +export const SettingsPersonal = () => { + const {onChangePageSize,pageSizeOptions,pageSize} = usePageSizeHelpers() + const {getTokenSilently} = useGetTokenSilently() + const dispatch = useDispatch() + + const users = useSelector((state: RootState) => state.user.data); + const personalAccessTokens = useSelector((state: RootState) => state.personalAccessToken.data); + const failed = useSelector((state: RootState) => state.personalAccessToken.failed); + const loading = useSelector((state: RootState) => state.personalAccessToken.loading); + const deletedPersonalAccessToken = useSelector((state: RootState) => state.personalAccessToken.deletedPersonalAccessToken); + const savedPersonalAccessToken = useSelector((state: RootState) => state.personalAccessToken.savedPersonalAccessToken); + + const personalAccessToken = useSelector((state: RootState) => state.personalAccessToken.personalAccessToken) + const inputNameRef = useRef(null) + + const [textToSearch, setTextToSearch] = useState(''); + const [dataTable, setDataTable] = useState([] as TokenDataTable[]); + + const optionsValidAll = [ {label: 'All', value: 'all'}, {label: 'Valid', value: 'valid'}, {label: 'Expired', value: 'expired'}] + const [optionValidAll, setOptionValidAll] = useState('all'); + const onChangeValidAll = ({target: {value}}: RadioChangeEvent) => { + setOptionValidAll(value) + } + + const [formPersonalAccessToken, setFormPersonalAccessToken] = useState({} as PersonalAccessTokenCreate) + const [form] = Form.useForm() + + const {oidcUser} = useOidcUser(); + + const [personalAccessTokenToDelete, setPersonalAccessTokenToDelete] = useState(null as PersonalAccessToken | null); + + const [addTokenModalOpen, setNewTokenModalOpen] = useState(false); + const [showPlainToken, setShowPlainToken] = useState(false); + const [tokenCopied, setTokenCopied] = useState(false); + const [plainToken, setPlainToken] = useState("") + + const [confirmModal, confirmModalContextHolder] = Modal.useModal(); + + const styleNotification = {marginTop: 85} + + const itemsMenuAction = [ + { + key: "delete", + label: () + }, + + ] + const actionsMenu = () + + useEffect(() => { + if (!personalAccessToken) return + setFormPersonalAccessToken(personalAccessToken) + form.setFieldsValue(personalAccessToken) + }, [personalAccessToken]) + + useEffect(() => { + dispatch(userActions.getUsers.request({getAccessTokenSilently: getTokenSilently, payload: null})); + }, []) + + const onChange = (data: any) => { + setFormPersonalAccessToken({...formPersonalAccessToken, ...data}) + } + + useEffect(() => { + if(oidcUser) { + dispatch(personalAccessTokenActions.getPersonalAccessTokens.request({ + getAccessTokenSilently: getTokenSilently, + payload: oidcUser.sub})); + } + }, [oidcUser]) + + useEffect(() => { + dispatch(userActions.getUsers.request({getAccessTokenSilently: getTokenSilently, payload: null})); + }, []) + + useEffect(() => { + setDataTable(filterDataTable(transformTokenTable(personalAccessTokens, users))) + }, [personalAccessTokens, textToSearch, users, optionValidAll]) + + const deleteKey = 'deleting'; + useEffect(() => { + if (deletedPersonalAccessToken.loading) { + message.loading({content: 'Deleting...', key: deleteKey, style: styleNotification}); + } else if (deletedPersonalAccessToken.success) { + message.success({ + content: 'Personal access token has been successfully removed.', + key: deleteKey, + duration: 2, + style: styleNotification + }); + dispatch(personalAccessTokenActions.setDeletePersonalAccessToken({...deletedPersonalAccessToken, success: false})) + dispatch(personalAccessTokenActions.resetDeletedPersonalAccessToken(null)) + } else if (deletedPersonalAccessToken.error) { + message.error({ + content: 'Failed to delete personal access token. You might not have enough permissions.', + key: deleteKey, + duration: 2, + style: styleNotification + }); + dispatch(personalAccessTokenActions.setDeletePersonalAccessToken({...deletedPersonalAccessToken, error: null})) + dispatch(personalAccessTokenActions.resetDeletedPersonalAccessToken(null)) + } + }, [deletedPersonalAccessToken]) + + const createKey = 'saving'; + useEffect(() => { + if (savedPersonalAccessToken.loading) { + message.loading({content: 'Saving...', key: createKey, duration: 0, style: styleNotification}); + } else if (savedPersonalAccessToken.success) { + message.destroy(createKey) + setPlainToken(savedPersonalAccessToken.data.plain_token) + setShowPlainToken(true) + } else if (savedPersonalAccessToken.error) { + message.error({ + content: 'Failed to create personal access token. You might not have enough permissions.', + key: createKey, + duration: 2, + style: styleNotification + }); + setNewTokenModalOpen(false) + setShowPlainToken(false) + setTokenCopied(false) + dispatch(personalAccessTokenActions.setSavedPersonalAccessToken({...savedPersonalAccessToken, error: null})); + dispatch(personalAccessTokenActions.resetSavedPersonalAccessToken(null)) + } + }, [savedPersonalAccessToken]) + + const transformTokenTable = (d: PersonalAccessToken[], u: User[]): TokenDataTable[] => { + if(!d) { + return [] + } + return d.map(p => ({ + key: p.id, + status: Date.parse(p.expiration_date) > Date.now() ? "valid" : "expired", + ...p} as TokenDataTable)) + } + + const filterDataTable = (f: TokenDataTable[]): TokenDataTable[] => { + const t = textToSearch.toLowerCase().trim() + switch (optionValidAll) { + case "valid": + f = filter(f, (_f: TokenDataTable) => _f.status === "valid") + break + case "expired": + f = filter(f, (_f: TokenDataTable) => _f.status === "expired") + break + default: + break + } + f = filter(f, (_f: TokenDataTable) => + (_f.name.toLowerCase().includes(t) || _f.status.toLowerCase().includes(t) || t === "") + ) as TokenDataTable[] + return f + } + + const onChangeTextToSearch = (e: React.ChangeEvent) => { + setTextToSearch(e.target.value) + }; + + const searchDataTable = () => { + setDataTable(filterDataTable(transformTokenTable(personalAccessTokens, users))) + } + + const showConfirmDelete = () => { + let name = personalAccessTokenToDelete ? personalAccessTokenToDelete.name : ''; + confirmModal.confirm({ + icon: , + title: "Delete token \"" + name + "\"", + width: 600, + content: + Are you sure you want to delete this token? + , + onOk() { + dispatch(personalAccessTokenActions.deletePersonalAccessToken.request({ + getAccessTokenSilently: getTokenSilently, + payload: { + user_id: oidcUser.sub, + id: personalAccessTokenToDelete ? personalAccessTokenToDelete.id : null, + name: personalAccessTokenToDelete ? personalAccessTokenToDelete.name : null, + } as SpecificPAT + })); + }, + onCancel() { + setPersonalAccessTokenToDelete(null); + }, + }); + } + + const onClickAddNewPersonalAccessToken = () => { + setNewTokenModalOpen(true) + dispatch(personalAccessTokenActions.setNewPersonalAccessTokenVisible(true)); + dispatch(personalAccessTokenActions.setPersonalAccessToken({ + user_id: "", + name: "", + expires_in: 7 + } as PersonalAccessTokenCreate)) + } + + const onCancel = () => { + setNewTokenModalOpen(false) + setShowPlainToken(false) + setTokenCopied(false) + if (savedPersonalAccessToken.loading) return + dispatch(personalAccessTokenActions.setPersonalAccessToken({ + user_id: "", + name: "", + expires_in: 0 + } as PersonalAccessTokenCreate)) + setFormPersonalAccessToken({} as PersonalAccessTokenCreate) + setVisibleNewSetupKey(false) + dispatch(personalAccessTokenActions.setNewPersonalAccessTokenVisible(false)); + dispatch(personalAccessTokenActions.setSavedPersonalAccessToken({...savedPersonalAccessToken, success: false})); + dispatch(personalAccessTokenActions.resetSavedPersonalAccessToken(null)) + } + + const setVisibleNewSetupKey = (status: boolean) => { + dispatch(personalAccessTokenActions.setNewPersonalAccessTokenVisible(status)); + } + + const createPersonalAccessTokenToSave = (): PersonalAccessTokenCreate => { + console.log(formPersonalAccessToken.name) + return { + user_id: oidcUser.sub, + name: formPersonalAccessToken.name, + expires_in: formPersonalAccessToken.expires_in, + } as PersonalAccessTokenCreate + } + + const handleFormSubmit = () => { + form.validateFields() + .then((values) => { + let personalAccessTokenToSave = createPersonalAccessTokenToSave() + dispatch(personalAccessTokenActions.savePersonalAccessToken.request({ + getAccessTokenSilently: getTokenSilently, + payload: personalAccessTokenToSave + })) + }) + .catch((errorInfo) => { + console.log('errorInfo', errorInfo) + }); + }; + + const onCopyClick = (text: string, copied: boolean) => { + copyToClipboard(text) + setTokenCopied(true) + if (copied) { + setTimeout(() => { + onCopyClick(text, false) + }, 2000) + } + } + + return ( + <> + + + + Personal Access Tokens + Personal Access Tokens can be used to authenticate against NetBird's Public API. + + + + + + + + + + + + + + + + Expires (Set the amount of days the token should be valid.) + } + rules={[{ + type: 'number', + min: 1, + max: 356, + message: 'The expiration should be set between 1 and 365 days' + }]}> + + + + + + + + + } + {showPlainToken && + + <> + + + {plainToken} + + + { !tokenCopied ? ( +