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:
pascal-fischer
2023-04-03 12:29:40 +02:00
committed by GitHub
parent 11fbfb336a
commit dff0313f82
19 changed files with 1133 additions and 227 deletions

View File

@@ -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});

View File

@@ -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 };

View 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;

View 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 };

View 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
});

View 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)
]);
}

View 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
);
},
};

View 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,
}

View File

@@ -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
};

View File

@@ -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
});

View File

@@ -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
}

View File

@@ -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() {

View File

@@ -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}

View File

@@ -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"}`}

View File

@@ -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() {

View File

@@ -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}
</>
)
}

View 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;

View 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;

View File

@@ -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({