mirror of
https://github.com/netbirdio/dashboard.git
synced 2026-01-26 01:21:04 +00:00
Feature/pat support (#157)
* Add working UI + API calls [missing token popup] * use popup view to add new token * show userID if user name not available * switch from description to name * show "Me" instead of own name * removed created_by column * update add token explanation * use object instead of plain text for token create response * some style changes * disable information button for tokens * last_used can contain nil * fix delete popups * lower case letters for dates * add activity and fix visibility * show settings tab for non admins * remove spaces on top of setting tabs * fix copy button size and position * fix list footers * continue merge changes to new files
This commit is contained in:
@@ -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});
|
||||
|
||||
@@ -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 };
|
||||
39
src/store/personal-access-token/actions.ts
Normal file
39
src/store/personal-access-token/actions.ts
Normal file
@@ -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',
|
||||
)<RequestPayload<string>, PersonalAccessToken[], ApiError>(),
|
||||
|
||||
savePersonalAccessToken: createAsyncAction(
|
||||
'SAVE_PERSONAL_ACCESS_TOKEN_REQUEST',
|
||||
'SAVE_PERSONAL_ACCESS_TOKEN_SUCCESS',
|
||||
'SAVE_PERSONAL_ACCESS_TOKEN_FAILURE',
|
||||
)<RequestPayload<PersonalAccessTokenCreate>, CreateResponse<PersonalAccessTokenGenerated | null>, CreateResponse<PersonalAccessTokenGenerated | null>>(),
|
||||
setSavedPersonalAccessToken: createAction('SET_PERSONAL_ACCESS_TOKEN_KEY')<CreateResponse<PersonalAccessTokenGenerated | null>>(),
|
||||
resetSavedPersonalAccessToken: createAction('RESET_PERSONAL_ACCESS_TOKEN_KEY')<null>(),
|
||||
|
||||
deletePersonalAccessToken: createAsyncAction(
|
||||
'DELETE_PERSONAL_ACCESS_TOKEN_REQUEST',
|
||||
'DELETE_PERSONAL_ACCESS_TOKEN_SUCCESS',
|
||||
'DELETE_PERSONAL_ACCESS_TOKEN_FAILURE'
|
||||
)<RequestPayload<SpecificPAT>, DeleteResponse<string | null>, DeleteResponse<string | null>>(),
|
||||
setDeletePersonalAccessToken: createAction('SET_DELETE_PERSONAL_ACCESS_TOKEN')<DeleteResponse<string | null>>(),
|
||||
resetDeletedPersonalAccessToken: createAction('RESET_DELETE_PERSONAL_ACCESS_TOKEN')<null>(),
|
||||
|
||||
removePersonalAccessToken: createAction('REMOVE_PERSONAL_ACCESS_TOKEN')<string>(),
|
||||
setPersonalAccessToken: createAction('SET_SETUP_KEY')<PersonalAccessTokenCreate>(),
|
||||
setNewPersonalAccessTokenVisible: createAction('SET_NEW_PERSONAL_ACCESS_TOKEN_VISIBLE')<boolean>()
|
||||
};
|
||||
|
||||
export type ActionTypes = ActionType<typeof actions>;
|
||||
export default actions;
|
||||
7
src/store/personal-access-token/index.ts
Normal file
7
src/store/personal-access-token/index.ts
Normal file
@@ -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 };
|
||||
97
src/store/personal-access-token/reducer.ts
Normal file
97
src/store/personal-access-token/reducer.ts
Normal file
@@ -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<string | null>;
|
||||
revokedPersonalAccessToken: ChangeResponse<string | null>;
|
||||
savedPersonalAccessToken: CreateResponse<PersonalAccessTokenGenerated | null>;
|
||||
newPersonalAccessTokenVisible: boolean
|
||||
}>;
|
||||
|
||||
const initialState: StateType = {
|
||||
data: [],
|
||||
personalAccessToken: null,
|
||||
loading: false,
|
||||
failed: null,
|
||||
saving: false,
|
||||
deletedPersonalAccessToken: <DeleteResponse<string | null>>{
|
||||
loading: false,
|
||||
success: false,
|
||||
failure: false,
|
||||
error: null,
|
||||
data : null
|
||||
},
|
||||
revokedPersonalAccessToken: <ChangeResponse<string | null>>{
|
||||
loading: false,
|
||||
success: false,
|
||||
failure: false,
|
||||
error: null,
|
||||
data : null
|
||||
},
|
||||
savedPersonalAccessToken: <CreateResponse<PersonalAccessTokenGenerated | null>>{
|
||||
loading: false,
|
||||
success: false,
|
||||
failure: false,
|
||||
error: null,
|
||||
data : null
|
||||
},
|
||||
newPersonalAccessTokenVisible: false
|
||||
};
|
||||
|
||||
const data = createReducer<PersonalAccessToken[], ActionTypes>(initialState.data as PersonalAccessToken[])
|
||||
.handleAction(actions.getPersonalAccessTokens.success,(_, action) => action.payload)
|
||||
.handleAction(actions.getPersonalAccessTokens.failure, () => []);
|
||||
|
||||
const personalAccessToken = createReducer<PersonalAccessTokenCreate, ActionTypes>(initialState.personalAccessToken as PersonalAccessTokenCreate)
|
||||
.handleAction(actions.setPersonalAccessToken, (store, action) => action.payload);
|
||||
|
||||
const loading = createReducer<boolean, ActionTypes>(initialState.loading)
|
||||
.handleAction(actions.getPersonalAccessTokens.request, () => true)
|
||||
.handleAction(actions.getPersonalAccessTokens.success, () => false)
|
||||
.handleAction(actions.getPersonalAccessTokens.failure, () => false);
|
||||
|
||||
const failed = createReducer<ApiError | null, ActionTypes>(initialState.failed)
|
||||
.handleAction(actions.getPersonalAccessTokens.request, () => null)
|
||||
.handleAction(actions.getPersonalAccessTokens.success, () => null)
|
||||
.handleAction(actions.getPersonalAccessTokens.failure, (store, action) => action.payload);
|
||||
|
||||
const saving = createReducer<boolean, ActionTypes>(initialState.saving)
|
||||
.handleAction(actions.getPersonalAccessTokens.request, () => true)
|
||||
.handleAction(actions.getPersonalAccessTokens.success, () => false)
|
||||
.handleAction(actions.getPersonalAccessTokens.failure, () => false);
|
||||
|
||||
const deletedPersonalAccessToken = createReducer<DeleteResponse<string | null>, 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<CreateResponse<PersonalAccessTokenGenerated | null>, 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<boolean, ActionTypes>(initialState.newPersonalAccessTokenVisible)
|
||||
.handleAction(actions.setNewPersonalAccessTokenVisible, (store, action) => action.payload)
|
||||
|
||||
export default combineReducers({
|
||||
data,
|
||||
personalAccessToken: personalAccessToken,
|
||||
loading,
|
||||
failed,
|
||||
saving,
|
||||
deletedPersonalAccessToken: deletedPersonalAccessToken,
|
||||
savedPersonalAccessToken: savedPersonalAccessToken,
|
||||
newPersonalAccessTokenVisible: newPersonalAccessTokenVisible
|
||||
});
|
||||
101
src/store/personal-access-token/sagas.ts
Normal file
101
src/store/personal-access-token/sagas.ts
Normal file
@@ -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<typeof actions.getPersonalAccessTokens.request>): Generator {
|
||||
try {
|
||||
const effect = yield call(service.getAllPersonalAccessTokens, action.payload);
|
||||
const response = effect as ApiResponse<PersonalAccessToken[]>;
|
||||
|
||||
yield put(actions.getPersonalAccessTokens.success(response.body));
|
||||
} catch (err) {
|
||||
yield put(actions.getPersonalAccessTokens.failure(err as ApiError));
|
||||
}
|
||||
}
|
||||
|
||||
export function* savePersonalAccessToken(action: ReturnType<typeof actions.savePersonalAccessToken.request>): Generator {
|
||||
try {
|
||||
yield put(actions.setSavedPersonalAccessToken({
|
||||
loading: true,
|
||||
success: false,
|
||||
failure: false,
|
||||
error: null,
|
||||
data: null
|
||||
} as CreateResponse<PersonalAccessTokenGenerated | null>))
|
||||
|
||||
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<PersonalAccessTokenGenerated>;
|
||||
|
||||
yield put(actions.savePersonalAccessToken.success({
|
||||
loading: false,
|
||||
success: true,
|
||||
failure: false,
|
||||
error: null,
|
||||
data: response.body
|
||||
} as CreateResponse<PersonalAccessTokenGenerated | null>));
|
||||
|
||||
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<PersonalAccessTokenGenerated | null>));
|
||||
}
|
||||
}
|
||||
|
||||
export function* deletePersonalAccessToken(action: ReturnType<typeof actions.deletePersonalAccessToken.request>): Generator {
|
||||
try {
|
||||
yield call(actions.setDeletePersonalAccessToken,{
|
||||
loading: true,
|
||||
success: false,
|
||||
failure: false,
|
||||
error: null,
|
||||
data: null
|
||||
} as DeleteResponse<string | null>)
|
||||
|
||||
const effect = yield call(service.deletePersonalAccessToken, action.payload);
|
||||
const response = effect as ApiResponse<any>;
|
||||
|
||||
yield put(actions.deletePersonalAccessToken.success({
|
||||
loading: false,
|
||||
success: true,
|
||||
failure: false,
|
||||
error: null,
|
||||
data: response.body
|
||||
} as DeleteResponse<string | null>));
|
||||
|
||||
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<string | null>));
|
||||
}
|
||||
}
|
||||
|
||||
export default function* sagas(): Generator {
|
||||
yield all([
|
||||
takeLatest(actions.getPersonalAccessTokens.request, getPersonalAccessTokens),
|
||||
takeLatest(actions.savePersonalAccessToken.request, savePersonalAccessToken),
|
||||
takeLatest(actions.deletePersonalAccessToken.request, deletePersonalAccessToken)
|
||||
]);
|
||||
}
|
||||
|
||||
34
src/store/personal-access-token/service.ts
Normal file
34
src/store/personal-access-token/service.ts
Normal file
@@ -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<string>): Promise<ApiResponse<PersonalAccessToken[]>> {
|
||||
return apiClient.get<PersonalAccessToken[]>(
|
||||
`/api/users/` + payload.payload + `/tokens`,
|
||||
payload
|
||||
);
|
||||
},
|
||||
async getPersonalAccessToken(payload:RequestPayload<SpecificPAT>): Promise<ApiResponse<PersonalAccessToken>> {
|
||||
return apiClient.get<PersonalAccessToken>(
|
||||
`/api/users/` + payload.payload.user_id + `/tokens/` + payload.payload.id,
|
||||
payload
|
||||
);
|
||||
},
|
||||
async deletePersonalAccessToken(payload:RequestPayload<SpecificPAT>): Promise<ApiResponse<any>> {
|
||||
return apiClient.delete<any>(
|
||||
`/api/users/` + payload.payload.user_id + `/tokens/` + payload.payload.id,
|
||||
payload
|
||||
);
|
||||
},
|
||||
async createPersonalAccessToken(payload:RequestPayload<PersonalAccessTokenCreate>): Promise<ApiResponse<PersonalAccessTokenGenerated>> {
|
||||
return apiClient.post<PersonalAccessTokenGenerated>(
|
||||
`/api/users/` + payload.payload.user_id + `/tokens`,
|
||||
payload
|
||||
);
|
||||
},
|
||||
};
|
||||
26
src/store/personal-access-token/types.ts
Normal file
26
src/store/personal-access-token/types.ts
Normal file
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -197,16 +197,13 @@ export const AccessControl = () => {
|
||||
}
|
||||
|
||||
const showConfirmDelete = () => {
|
||||
let name = ruleToAction ? ruleToAction.name : '';
|
||||
confirm({
|
||||
icon: <ExclamationCircleOutlined/>,
|
||||
title: "Delete rule \"" + name + "\"",
|
||||
width: 600,
|
||||
content: <Space direction="vertical" size="small">
|
||||
{ruleToAction &&
|
||||
<>
|
||||
<Title level={5}>Delete rule "{ruleToAction ? ruleToAction.name : ''}"</Title>
|
||||
<Paragraph>Are you sure you want to delete this rule from your account?</Paragraph>
|
||||
</>
|
||||
}
|
||||
<Paragraph>Are you sure you want to delete this rule from your account?</Paragraph>
|
||||
</Space>,
|
||||
okType: 'danger',
|
||||
onOk() {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -153,16 +153,13 @@ export const Nameservers = () => {
|
||||
}
|
||||
|
||||
const showConfirmDelete = () => {
|
||||
let name = nsGroupToAction ? nsGroupToAction.name : '';
|
||||
confirm({
|
||||
icon: <ExclamationCircleOutlined/>,
|
||||
title: "Delete Nameserver group \"" + name + "\"",
|
||||
width: 600,
|
||||
content: <Space direction="vertical" size="small">
|
||||
{nsGroupToAction &&
|
||||
<>
|
||||
<Title level={5}>Delete Nameserver group "{nsGroupToAction ? nsGroupToAction.name : ''}"</Title>
|
||||
<Paragraph>Are you sure you want to delete this nameserver group from your account?</Paragraph>
|
||||
</>
|
||||
}
|
||||
<Paragraph>Are you sure you want to delete this nameserver group from your account?</Paragraph>
|
||||
</Space>,
|
||||
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"}`}
|
||||
|
||||
@@ -206,16 +206,13 @@ export const Routes = () => {
|
||||
}
|
||||
|
||||
const showConfirmDelete = () => {
|
||||
let name = routeToAction ? routeToAction.network_id : '';
|
||||
confirm({
|
||||
icon: <ExclamationCircleOutlined/>,
|
||||
title: "Delete network route \"" + name + "\"",
|
||||
width: 600,
|
||||
content: <Space direction="vertical" size="small">
|
||||
{routeToAction &&
|
||||
<>
|
||||
<Title level={5}>Delete netowork route "{routeToAction ? routeToAction.network_id : ''}"</Title>
|
||||
<Paragraph>Are you sure you want to delete this route from your account?</Paragraph>
|
||||
</>
|
||||
}
|
||||
<Paragraph>Are you sure you want to delete this route from your account?</Paragraph>
|
||||
</Space>,
|
||||
okType: 'danger',
|
||||
onOk() {
|
||||
|
||||
@@ -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: <SettingsPersonal/>,
|
||||
},
|
||||
]
|
||||
|
||||
const [form] = Form.useForm()
|
||||
const adminOnlyItems: TabsProps['items'] = [
|
||||
{
|
||||
key: '2',
|
||||
label: 'Account Settings',
|
||||
children: <SettingsAccount/>,
|
||||
},
|
||||
]
|
||||
|
||||
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: <ExclamationCircleOutlined/>,
|
||||
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 = () => {
|
||||
<Container style={{paddingTop: "40px"}}>
|
||||
<Row>
|
||||
<Col span={24}>
|
||||
<Title level={4}>Settings</Title>
|
||||
<Paragraph>Manage your account's settings</Paragraph>
|
||||
<Space direction="vertical" size="large" style={{display: 'flex'}}>
|
||||
|
||||
<Card bodyStyle={{padding: 0}}>
|
||||
<Form
|
||||
name="basic"
|
||||
autoComplete="off"
|
||||
form={form}
|
||||
onFinish={handleFormSubmit}
|
||||
>
|
||||
<Space direction={"vertical"}
|
||||
style={{display: 'flex'}}>
|
||||
<Card
|
||||
title="Authentication"
|
||||
loading={loading}
|
||||
defaultValue={"Enabled"}
|
||||
>
|
||||
<Form.Item
|
||||
label="Peer login expiration"
|
||||
name="peer_login_expiration_enabled"
|
||||
tooltip="Peer login expiration allows to periodically request re-authentication of peers that were added with the SSO login. You can disable the expiration per peer in the peers tab."
|
||||
//rules={[{validator: selectValidatorEmptyStrings}]}
|
||||
>
|
||||
<Radio.Group
|
||||
options={[{label: 'Enabled', value: true}, {
|
||||
label: 'Disabled',
|
||||
value: false
|
||||
}]}
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
onChange={function (e) {
|
||||
setFormPeerExpirationEnabled(e.target.value)
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="peer_login_expiration_formatted"
|
||||
label="Peer login expires in"
|
||||
tooltip="Time after which every peer added with SSO login will require re-authentication."
|
||||
rules={[{validator: checkExpiresIn}]}>
|
||||
<ExpiresInInput
|
||||
disabled={!formPeerExpirationEnabled}
|
||||
options={Array.of({key: "hour", title: "Hours"}, {
|
||||
key: "day",
|
||||
title: "Days"
|
||||
})
|
||||
}/>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button icon={<QuestionCircleFilled/>} type="link" target="_blank"
|
||||
href="https://netbird.io/docs/how-to-guides/periodic-authentication">Learn more about login expiration</Button>
|
||||
</Form.Item>
|
||||
</Card>
|
||||
<Form.Item style={{textAlign: 'center'}}>
|
||||
<Button type="primary" htmlType="submit">
|
||||
Save
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Space>
|
||||
</Form>
|
||||
</Card>
|
||||
</Space>
|
||||
<Tabs
|
||||
defaultActiveKey={nsTabKey}
|
||||
items={tabItems}
|
||||
onTabClick={onTabClick}
|
||||
animated={{ inkBar: true, tabPane: false }}
|
||||
tabPosition="top"
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
<UserUpdate/>
|
||||
{confirmModalContextHolder}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
229
src/views/SettingsAccount.tsx
Normal file
229
src/views/SettingsAccount.tsx
Normal file
@@ -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: <ExclamationCircleOutlined/>,
|
||||
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 (
|
||||
<>
|
||||
<Container>
|
||||
<Row>
|
||||
<Col span={24}>
|
||||
<Title level={4}>Settings</Title>
|
||||
<Paragraph>Manage your account's settings</Paragraph>
|
||||
<Space direction="vertical" size="large" style={{display: 'flex'}}>
|
||||
|
||||
<Card bodyStyle={{padding: 0}}>
|
||||
<Form
|
||||
name="basic"
|
||||
autoComplete="off"
|
||||
form={form}
|
||||
onFinish={handleFormSubmit}
|
||||
>
|
||||
<Space direction={"vertical"}
|
||||
style={{display: 'flex'}}>
|
||||
<Card
|
||||
title="Authentication"
|
||||
loading={loading}
|
||||
defaultValue={"Enabled"}
|
||||
>
|
||||
<Form.Item
|
||||
label="Peer login expiration"
|
||||
name="peer_login_expiration_enabled"
|
||||
tooltip="Peer login expiration allows to periodically request re-authentication of peers that were added with the SSO login. You can disable the expiration per peer in the peers tab."
|
||||
//rules={[{validator: selectValidatorEmptyStrings}]}
|
||||
>
|
||||
<Radio.Group
|
||||
options={[{label: 'Enabled', value: true}, {
|
||||
label: 'Disabled',
|
||||
value: false
|
||||
}]}
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
onChange={function (e) {
|
||||
setFormPeerExpirationEnabled(e.target.value)
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="peer_login_expiration_formatted"
|
||||
label="Peer login expires in"
|
||||
tooltip="Time after which every peer added with SSO login will require re-authentication."
|
||||
rules={[{validator: checkExpiresIn}]}>
|
||||
<ExpiresInInput
|
||||
disabled={!formPeerExpirationEnabled}
|
||||
options={Array.of({key: "hour", title: "Hours"}, {
|
||||
key: "day",
|
||||
title: "Days"
|
||||
})
|
||||
}/>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button icon={<QuestionCircleFilled/>} type="link" target="_blank"
|
||||
href="https://netbird.io/docs/how-to-guides/periodic-authentication">Learn more about login expiration</Button>
|
||||
</Form.Item>
|
||||
</Card>
|
||||
<Form.Item style={{textAlign: 'center'}}>
|
||||
<Button type="primary" htmlType="submit">
|
||||
Save
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Space>
|
||||
</Form>
|
||||
</Card>
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
<UserUpdate/>
|
||||
{confirmModalContextHolder}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Settings;
|
||||
514
src/views/SettingsPersonal.tsx
Normal file
514
src/views/SettingsPersonal.tsx
Normal file
@@ -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<any>(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: (<Button type="text" onClick={() => showConfirmDelete()}>Delete</Button>)
|
||||
},
|
||||
|
||||
]
|
||||
const actionsMenu = (<Menu items={itemsMenuAction}></Menu>)
|
||||
|
||||
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<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
setTextToSearch(e.target.value)
|
||||
};
|
||||
|
||||
const searchDataTable = () => {
|
||||
setDataTable(filterDataTable(transformTokenTable(personalAccessTokens, users)))
|
||||
}
|
||||
|
||||
const showConfirmDelete = () => {
|
||||
let name = personalAccessTokenToDelete ? personalAccessTokenToDelete.name : '';
|
||||
confirmModal.confirm({
|
||||
icon: <ExclamationCircleOutlined/>,
|
||||
title: "Delete token \"" + name + "\"",
|
||||
width: 600,
|
||||
content: <Space direction="vertical" size="small">
|
||||
<Paragraph>Are you sure you want to delete this token?</Paragraph>
|
||||
</Space>,
|
||||
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 (
|
||||
<>
|
||||
<Container>
|
||||
<Row>
|
||||
<Col span={24}>
|
||||
<Title level={4}>Personal Access Tokens</Title>
|
||||
<Paragraph>Personal Access Tokens can be used to authenticate against NetBird's Public API.</Paragraph>
|
||||
<Space direction="vertical" size="large" style={{display: 'flex'}}>
|
||||
<Row gutter={[16, 24]}>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8} xxl={8} span={8}>
|
||||
<Input allowClear value={textToSearch} onPressEnter={searchDataTable}
|
||||
placeholder="Search..." onChange={onChangeTextToSearch}/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={11} lg={11} xl={11} xxl={11} span={11}>
|
||||
<Space size="middle">
|
||||
<Radio.Group
|
||||
options={optionsValidAll}
|
||||
onChange={onChangeValidAll}
|
||||
value={optionValidAll}
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
/>
|
||||
<Select value={pageSize.toString()} options={pageSizeOptions}
|
||||
onChange={onChangePageSize} className="select-rows-per-page-en"/>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col xs={24}
|
||||
sm={24}
|
||||
md={5}
|
||||
lg={5}
|
||||
xl={5}
|
||||
xxl={5} span={5}>
|
||||
<Row justify="end">
|
||||
<Col>
|
||||
<Button type="primary" onClick={onClickAddNewPersonalAccessToken}>Add Token</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
</Col>
|
||||
</Row>
|
||||
{failed &&
|
||||
<Alert message={failed.message} description={failed.data ? failed.data.message : " "} type="error" showIcon
|
||||
closable/>
|
||||
}
|
||||
<Card bodyStyle={{padding: 0}}>
|
||||
<Table
|
||||
pagination={{
|
||||
pageSize,
|
||||
showSizeChanger: false,
|
||||
showTotal: ((total, range) => `Showing ${range[0]} to ${range[1]} of ${total} personal access tokens`)
|
||||
}}
|
||||
className="card-table"
|
||||
showSorterTooltip={false}
|
||||
scroll={{x: true}}
|
||||
loading={tableSpin(loading)}
|
||||
dataSource={dataTable}>
|
||||
<Column title="Name" dataIndex="name"
|
||||
onFilter={(value: string | number | boolean, record) => (record as any).name.includes(value)}
|
||||
sorter={(a, b) => ((a as any).name.localeCompare((b as any).name))}
|
||||
render={(text, record, index) => {
|
||||
return <Text strong>{text}</Text>
|
||||
}}
|
||||
/>
|
||||
|
||||
<Column title="Last Used" dataIndex="last_used"
|
||||
sorter={(a, b) => ((a as any).last_used.localeCompare((b as any).last_used))}
|
||||
render={(text, record, index) => {
|
||||
return isNil((record as TokenDataTable).last_used) ? "never" : timeAgo(text)
|
||||
}}
|
||||
/>
|
||||
<Column title="Created" dataIndex="created_at"
|
||||
sorter={(a, b) => ((a as any).created_at.localeCompare((b as any).created_at))}
|
||||
render={(text, record, index) => {
|
||||
return <Text>{timeAgo(text)}</Text>
|
||||
}}
|
||||
defaultSortOrder='descend'
|
||||
/>
|
||||
|
||||
<Column title="Status" dataIndex="status"
|
||||
sorter={(a, b) => ((a as any).status.localeCompare((b as any).status))}
|
||||
render={(text, record, index) => {
|
||||
return (text === 'valid') ? <Tag color="green">{text}</Tag> :
|
||||
<Tag color="red">{text}</Tag>
|
||||
}}
|
||||
/>
|
||||
|
||||
<Column title="Expires" dataIndex="expiration_date"
|
||||
sorter={(a, b) => ((a as any).expiration_date.localeCompare((b as any).expiration_date))}
|
||||
render={(text, record, index) => {
|
||||
return timeAgo(text)
|
||||
}}
|
||||
/>
|
||||
|
||||
<Column title="" align="center"
|
||||
render={(text, record, index) => {
|
||||
return (
|
||||
<Dropdown.Button type="text" overlay={actionsMenu}
|
||||
trigger={["click"]}
|
||||
onOpenChange={visible => {
|
||||
if (visible) setPersonalAccessTokenToDelete(record as PersonalAccessToken)
|
||||
}}></Dropdown.Button>)
|
||||
}}
|
||||
/>
|
||||
</Table>
|
||||
</Card>
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
</Container>
|
||||
<Modal
|
||||
open={addTokenModalOpen}
|
||||
onCancel={onCancel}
|
||||
footer={
|
||||
<Space style={{display: 'flex', justifyContent: 'end'}}>
|
||||
<Button disabled={savedPersonalAccessToken.loading} onClick={onCancel}>{showPlainToken ? "Close" : "Cancel"}</Button>
|
||||
<Button type="primary" disabled={showPlainToken}
|
||||
onClick={handleFormSubmit}>{"Create"}</Button>
|
||||
</Space>
|
||||
}
|
||||
width={780}
|
||||
>
|
||||
<Container style={{textAlign: "center"}}>
|
||||
<Paragraph
|
||||
style={{textAlign: "center", whiteSpace: "pre-line", fontSize: "2em"}}>
|
||||
{showPlainToken ? "Token created successfully!" : "Create new Personal Access Token"}
|
||||
</Paragraph>
|
||||
<Paragraph type={"secondary"}
|
||||
style={{
|
||||
textAlign: "center",
|
||||
whiteSpace: "pre-line",
|
||||
marginTop: "-15px",
|
||||
paddingBottom: "25px",
|
||||
}}>
|
||||
{showPlainToken ? "You will only see this token once," + "\n" + "so copy it and store it in a secure location." : "This token can be used to authenticate against NetBird's Public API."}
|
||||
</Paragraph>
|
||||
{!showPlainToken && <Form layout="vertical" hideRequiredMark form={form} onValuesChange={onChange}
|
||||
initialValues={{
|
||||
expires_in: ExpiresInDefault,
|
||||
}}
|
||||
style={{paddingLeft: "80px", paddingRight: "80px"}}
|
||||
>
|
||||
<Row gutter={16}>
|
||||
<Col span={24}>
|
||||
<Divider style={{marginTop: "0px"}}></Divider>
|
||||
<Row align="top">
|
||||
<Col flex="auto">
|
||||
<Form.Item
|
||||
name="name"
|
||||
label={
|
||||
<Text style={{color: "gray"}}><b style={{color: "black"}}>Name</b> (Set a name to identify the token.)</Text>
|
||||
}
|
||||
rules={[{
|
||||
required: true,
|
||||
message: 'Please add a name for this personal access token',
|
||||
whitespace: true
|
||||
}]}
|
||||
>
|
||||
<Input
|
||||
placeholder={""}
|
||||
ref={inputNameRef}
|
||||
autoComplete="off"/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Divider style={{marginTop: "0px"}}></Divider>
|
||||
</Col>
|
||||
<Col span={24} style={{textAlign: "left"}}>
|
||||
<Form.Item
|
||||
name="expires_in"
|
||||
label={
|
||||
<Text style={{color: "gray"}}><b style={{color: "black"}}>Expires</b> (Set the amount of days the token should be valid.)</Text>
|
||||
}
|
||||
rules={[{
|
||||
type: 'number',
|
||||
min: 1,
|
||||
max: 356,
|
||||
message: 'The expiration should be set between 1 and 365 days'
|
||||
}]}>
|
||||
<InputNumber/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Divider style={{marginTop: "0px"}}></Divider>
|
||||
<Button icon={<QuestionCircleFilled/>} type="link" target="_blank" disabled={true}
|
||||
href="https://netbird.io/docs/overview/personal-access-tokens">Learn more about personal access tokens</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>}
|
||||
{showPlainToken && <Space className="nb-code" direction="vertical" size="middle">
|
||||
<Row>
|
||||
<>
|
||||
<Space className="nb-code" direction="vertical" size="small" style={{display: "flex", fontSize: ".85em"}}>
|
||||
<SyntaxHighlighter language="bash">
|
||||
{plainToken}
|
||||
</SyntaxHighlighter>
|
||||
</Space>
|
||||
{ !tokenCopied ? (
|
||||
<Button type="text" size="middle" className="btn-copy-code" icon={<CopyOutlined/>}
|
||||
style={{color: "rgb(107, 114, 128)", marginTop: "-1px"}}
|
||||
onClick={() => onCopyClick(plainToken, true)}/>
|
||||
): (
|
||||
<Button type="text" size="middle" className="btn-copy-code" icon={<CheckOutlined/>}
|
||||
style={{color: "green", marginTop: "-1px"}}/>
|
||||
)}
|
||||
</>
|
||||
</Row>
|
||||
</Space>}
|
||||
</Container>
|
||||
</Modal>
|
||||
{confirmModalContextHolder}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default SettingsPersonal;
|
||||
@@ -177,16 +177,13 @@ export const SetupKeys = () => {
|
||||
}
|
||||
|
||||
const showConfirmRevoke = () => {
|
||||
let name = setupKeyToAction ? setupKeyToAction.name : ''
|
||||
confirmModal.confirm({
|
||||
icon: <ExclamationCircleOutlined/>,
|
||||
title: "Revoke setupKey \"" + name + "\"",
|
||||
width: 600,
|
||||
content: <Space direction="vertical" size="small">
|
||||
{setupKeyToAction &&
|
||||
<>
|
||||
<Title level={5}>Revoke setupKey "{setupKeyToAction ? setupKeyToAction.name : ''}"</Title>
|
||||
<Paragraph>Are you sure you want to revoke key?</Paragraph>
|
||||
</>
|
||||
}
|
||||
<Paragraph>Are you sure you want to revoke key?</Paragraph>
|
||||
</Space>,
|
||||
onOk() {
|
||||
dispatch(setupKeyActions.saveSetupKey.request({
|
||||
|
||||
Reference in New Issue
Block a user