feat(xo-web/user): user tokens management through XO interface (#6276)

This commit is contained in:
Mathieu 2022-06-28 17:57:59 +02:00 committed by GitHub
parent 87f1f208c3
commit c7df11cc6f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 209 additions and 24 deletions

View File

@ -9,6 +9,7 @@
- [Backup] Merge delta backups without copying data when using VHD directories on NFS/SMB/local remote(https://github.com/vatesfr/xen-orchestra/pull/6271))
- [Proxies] Ability to copy the proxy access URL (PR [#6287](https://github.com/vatesfr/xen-orchestra/pull/6287))
- [User] User tokens management through XO interface (PR [#6276](https://github.com/vatesfr/xen-orchestra/pull/6276))
### Bug fixes

View File

@ -25,8 +25,8 @@ create.params = {
// -------------------------------------------------------------------
async function delete_({ token: id }) {
await this.deleteAuthenticationToken(id)
async function delete_({ pattern, tokens }) {
await this.deleteAuthenticationTokens({ filter: pattern ?? { id: { __or: tokens } } })
}
export { delete_ as delete }
@ -34,26 +34,8 @@ export { delete_ as delete }
delete_.description = 'delete an existing authentication token'
delete_.params = {
token: { type: 'string' },
}
// -------------------------------------------------------------------
export async function deleteAll({ except }) {
await this.deleteAuthenticationTokens({
filter: {
user_id: this.apiContext.user.id,
id: {
__not: except,
},
},
})
}
deleteAll.description = 'delete all tokens of the current user except the current one'
deleteAll.params = {
except: { type: 'string', optional: true },
tokens: { type: 'array', optional: true, items: { type: 'string' } },
pattern: { type: 'object', optional: true },
}
// -------------------------------------------------------------------

View File

@ -4,8 +4,12 @@
const forEach = require('lodash/forEach')
const messages = {
creation: 'Creation',
description: 'Description',
expiration: 'Expiration',
keyValue: '{key}: {value}',
notDefined: 'Not defined',
statusConnecting: 'Connecting',
statusDisconnected: 'Disconnected',
statusLoading: 'Loading…',
@ -2046,6 +2050,8 @@ const messages = {
pifPhysicallyDisconnected: 'Physically disconnected',
// ----- User -----
authToken: 'Token',
authTokens: 'Authentication tokens',
username: 'Username',
password: 'Password',
language: 'Language',
@ -2065,15 +2071,23 @@ const messages = {
forgetTokensSuccess: 'Successfully forgot connection tokens',
forgetTokensError: 'Error while forgetting connection tokens',
sshKeys: 'SSH keys',
newAuthToken: 'New token',
newSshKey: 'New SSH key',
deleteAuthTokens: 'Delete selected authentication tokens',
deleteSshKey: 'Delete',
deleteSshKeys: 'Delete selected SSH keys',
newAuthTokenModalTitle: 'New authentication token',
newSshKeyModalTitle: 'New SSH key',
sshKeyAlreadyExists: 'SSH key already exists!',
sshKeyErrorTitle: 'Invalid key',
sshKeyErrorMessage: 'An SSH key requires both a title and a key.',
title: 'Title',
key: 'Key',
deleteAuthTokenConfirm: 'Delete authentication token',
deleteAuthTokenConfirmMessage: 'Are you sure you want to delete the authentication token: {id}?',
deleteAuthTokensConfirm: 'Delete authentication token{nTokens, plural, one {} other {s}}',
deleteAuthTokensConfirmMessage:
'Are you sure you want to delete {nTokens, number} autentication token{nTokens, plural, one {} other {s}}?',
deleteSshKeyConfirm: 'Delete SSH key',
deleteSshKeyConfirmMessage: 'Are you sure you want to delete the SSH key {title}?',
deleteSshKeysConfirm: 'Delete SSH key{nKeys, plural, one {} other {s}}',

View File

@ -516,6 +516,10 @@ export const createFakeProgress = (() => {
}
})()
export const NumericDate = ({ timestamp }) => (
<FormattedDate day='2-digit' hour='numeric' minute='numeric' month='2-digit' value={timestamp} year='numeric' />
)
export const ShortDate = ({ timestamp }) => (
<FormattedDate value={timestamp} month='short' day='numeric' year='numeric' />
)

View File

@ -19,6 +19,7 @@ import fetch, { post } from '../fetch'
import invoke from '../invoke'
import Icon from '../icon'
import logError from '../log-error'
import NewAuthTokenModal from './new-auth-token-modal'
import renderXoItem, { renderXoItemFromId } from '../render-xo-item'
import store from 'store'
import { alert, chooseAction, confirm } from '../modal'
@ -539,6 +540,8 @@ export const createSrUnhealthyVdiChainsLengthSubscription = sr => {
return subscription
}
export const subscribeUserAuthTokens = createSubscription(() => _call('user.getAuthenticationTokens'))
// System ============================================================
export const apiMethods = _call('system.getMethodsInfo')
@ -2782,7 +2785,14 @@ export const deleteUsers = users =>
export const editUser = (user, { email, password, permission }) =>
_call('user.set', { id: resolveId(user), email, password, permission })::tap(subscribeUsers.forceRefresh)
const _signOutFromEverywhereElse = () => _call('token.deleteAll', { except: cookies.get('token') })
const _signOutFromEverywhereElse = () =>
_call('token.delete', {
pattern: {
id: {
__not: cookies.get('token'),
},
},
})
export const signOutFromEverywhereElse = () =>
_signOutFromEverywhereElse().then(
@ -2890,6 +2900,47 @@ export const deleteSshKeys = keys =>
})
}, noop)
export const addAuthToken = async () => {
const { description, expiration } = await confirm({
body: <NewAuthTokenModal />,
icon: 'user',
title: _('newAuthTokenModalTitle'),
})
const expires = new Date(expiration).setHours(23, 59, 59)
return _call('token.create', {
description,
expiresIn: Number.isNaN(expires) ? undefined : expires - new Date().getTime(),
})::tap(subscribeUserAuthTokens.forceRefresh)
}
export const deleteAuthToken = async ({ id }) => {
await confirm({
body: _('deleteAuthTokenConfirmMessage', {
id,
}),
icon: 'user',
title: _('deleteAuthTokenConfirm'),
})
return _call('token.delete', { tokens: [id] })::tap(subscribeUserAuthTokens.forceRefresh)
}
export const deleteAuthTokens = async tokens => {
await confirm({
body: _('deleteAuthTokensConfirmMessage', {
nTokens: tokens.length,
}),
icon: 'user',
title: _('deleteAuthTokensConfirm', { nTokens: tokens.length }),
})
return _call('token.delete', { tokens: tokens.map(token => token.id) })::tap(subscribeUserAuthTokens.forceRefresh)
}
export const editAuthToken = ({ description, id }) =>
_call('token.set', {
description,
id,
})::tap(subscribeUserAuthTokens.forceRefresh)
// User filters --------------------------------------------------
import AddUserFilterModalBody from './add-user-filter-modal' // eslint-disable-line import/first

View File

@ -0,0 +1,48 @@
import BaseComponent from 'base-component'
import React from 'react'
import _ from '../../intl'
import SingleLineRow from '../../single-line-row'
import { Col } from '../../grid'
export default class NewAuthTokenModal extends BaseComponent {
get value() {
return this.state
}
render() {
const { description, expiration } = this.state
return (
<div>
<div className='pb-1'>
<SingleLineRow>
<Col size={4}>{_('expiration')}</Col>
<Col size={8}>
<input
className='form-control'
min={new Date().toISOString().split('T')[0]}
onChange={this.linkState('expiration')}
type='date'
value={expiration ?? ''}
/>
</Col>
</SingleLineRow>
</div>
<div className='pb-1'>
<SingleLineRow>
<Col size={4}>{_('description')}</Col>
<Col size={8}>
<textarea
className='form-control'
onChange={this.linkState('description')}
rows={10}
value={description ?? ''}
/>
</Col>
</SingleLineRow>
</div>
</div>
)
}
}

View File

@ -2,6 +2,7 @@ import * as FormGrid from 'form-grid'
import _, { messages } from 'intl'
import ActionButton from 'action-button'
import Component from 'base-component'
import Copiable from 'copiable'
import homeFilters from 'home-filters'
import Icon from 'icon'
import PropTypes from 'prop-types'
@ -16,16 +17,21 @@ import { isEmpty, map } from 'lodash'
import { injectIntl } from 'react-intl'
import { Select } from 'form'
import { Card, CardBlock, CardHeader } from 'card'
import { addSubscriptions, connectStore, noop } from 'utils'
import { addSubscriptions, connectStore, noop, NumericDate } from 'utils'
import {
addAuthToken,
addSshKey,
changePassword,
deleteAuthToken,
deleteAuthTokens,
deleteSshKey,
deleteSshKeys,
editAuthToken,
editCustomFilter,
removeCustomFilter,
setDefaultHomeFilter,
signOutFromEverywhereElse,
subscribeUserAuthTokens,
subscribeCurrentUser,
} from 'xo'
@ -282,6 +288,83 @@ const SshKeys = addSubscriptions({
)
})
// ===================================================================
const COLUMNS_AUTH_TOKENS = [
{
itemRenderer: ({ id }) => (
<Copiable tagName='pre' data={id}>
{id.slice(0, 5)}
</Copiable>
),
name: _('authToken'),
},
{
itemRenderer: token => (
<Text value={token.description ?? ''} onChange={description => editAuthToken({ ...token, description })} />
),
name: _('description'),
sortCriteria: 'description',
},
{
itemRenderer: ({ created_at }) => {
if (created_at !== undefined) {
return <NumericDate timestamp={created_at} />
}
return _('notDefined')
},
name: _('creation'),
sortCriteria: 'created_at',
},
{
default: true,
itemRenderer: ({ expiration }) => <NumericDate timestamp={expiration} />,
name: _('expiration'),
sortCriteria: 'expiration',
},
]
const INDIVIDUAL_ACTIONS_AUTH_TOKENS = [
{
handler: deleteAuthToken,
icon: 'delete',
label: _('delete'),
level: 'danger',
},
]
const GROUPED_ACTIONS_AUTH_TOKENS = [
{
handler: deleteAuthTokens,
icon: 'delete',
label: _('deleteAuthTokens'),
level: 'danger',
},
]
const UserAuthTokens = addSubscriptions({
userAuthTokens: subscribeUserAuthTokens,
})(({ userAuthTokens }) => (
<div>
<Card>
<CardHeader>
<Icon icon='user' /> {_('authTokens')}
<ActionButton className='btn-success pull-right' icon='add' handler={addAuthToken}>
{_('newAuthToken')}
</ActionButton>
</CardHeader>
<CardBlock>
<SortedTable
collection={userAuthTokens}
columns={COLUMNS_AUTH_TOKENS}
stateUrlParam='s_auth_tokens'
groupedActions={GROUPED_ACTIONS_AUTH_TOKENS}
individualActions={INDIVIDUAL_ACTIONS_AUTH_TOKENS}
/>
</CardBlock>
</Card>
</div>
))
// ===================================================================
@addSubscriptions({
@ -412,6 +495,8 @@ export default class User extends Component {
]}
<SshKeys />
<hr />
<UserAuthTokens />
<hr />
<UserFilters user={user} />
</Page>
)