-
Notifications
You must be signed in to change notification settings - Fork 56
Allow users to generate API tokens with the desired access #410
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 49 commits
4477394
53e4850
fece8bf
cc1abc6
a71baba
bb0f20c
76a8279
c10d9f6
f695e23
3b39341
9ad0610
77adcd2
a13dd2c
ff91ba7
8a9ebc9
75e3014
b2a338d
e113d84
8503ae0
00145dd
b22a7a3
8703ace
60fd8d4
a5324ef
0456bb7
14db0c6
93a73ce
dd48e19
c1ab686
a633d16
e9a1d23
58cb185
8c47cf7
dd86f0b
1f804b5
aa2ff39
a2da3b8
6cd4bbc
b5c9054
9022051
b0abf21
2198f27
b29429f
95f0f40
8fb651b
3f0fef9
7db9f7a
a3f90ff
0bb6853
3828efc
f77e80f
58e29d3
70c4793
851a457
aa9f77b
5f7050e
60f6977
e4e0b1e
16dae00
f9a7e8b
d5ebbb9
3c4d5b6
a89c7b6
a5b2142
2ee8226
f8f9778
e6ff7e2
0a05dae
f5b37c2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,132 @@ | ||
| import moment from 'moment' | ||
| import React, { useState } from 'react' | ||
| import { MomentDateFormat } from '../../config' | ||
| import { ReactComponent as Key } from '../../assets/icons/ic-key-bulb.svg' | ||
| import { ReactComponent as Edit } from '../../assets/icons/ic-pencil.svg' | ||
| import { ReactComponent as Trash } from '../../assets/icons/ic-delete-interactive.svg' | ||
| import { useHistory } from 'react-router-dom' | ||
| import { APITokenListType, TokenListType } from './authorization.type' | ||
| import { isTokenExpired } from './authorization.utils' | ||
| import DeleteAPITokenModal from './DeleteAPITokenModal' | ||
| import NoResults from '../../assets/img/[email protected]' | ||
| import './apiToken.scss' | ||
| import EmptyState from '../EmptyState/EmptyState' | ||
|
|
||
| function NoMatchingResults() { | ||
| return ( | ||
| <EmptyState> | ||
| <EmptyState.Image> | ||
| <img src={NoResults} width="250" height="200" alt="No matching results" /> | ||
| </EmptyState.Image> | ||
| <EmptyState.Title> | ||
| <h2 className="fs-16 fw-4 c-9">No matching results</h2> | ||
| </EmptyState.Title> | ||
| <EmptyState.Subtitle>We couldn't find any matching token</EmptyState.Subtitle> | ||
| </EmptyState> | ||
| ) | ||
| } | ||
|
|
||
| function APITokenList({ tokenList, renderSearchToken, reload }: APITokenListType) { | ||
| const history = useHistory() | ||
| const [showDeleteConfirmation, setDeleteConfirmation] = useState(false) | ||
| const [selectedToken, setSelectedToken] = useState<TokenListType>() | ||
|
|
||
| const handleGenerateRowActionButton = (key: 'create' | 'edit', id?) => { | ||
| history.push(id ? `${key}/${id}` : key) | ||
| } | ||
|
|
||
| return ( | ||
| <div> | ||
| <div className="cn-9 fw-6 fs-16">API tokens</div> | ||
| <p className="fs-13 fw-4">Tokens you have generated that can be used to access the Devtron API.</p> | ||
| <div className="flex content-space"> | ||
| <button className="flex cta h-32" onClick={() => handleGenerateRowActionButton('create')}> | ||
| Generate new token | ||
| </button> | ||
| {renderSearchToken()} | ||
| </div> | ||
| <div | ||
| className="mt-16 en-2 bw-1 bcn-0 br-8" | ||
| style={{ minHeight: 'calc(100vh - 235px)', overflow: 'hidden' }} | ||
| > | ||
| <div className="api-list-row fw-6 cn-7 fs-12 border-bottom pt-10 pb-10 pr-20 pl-20 text-uppercase"> | ||
| <div></div> | ||
| <div>Name</div> | ||
| <div>Last Used On</div> | ||
| <div>Ip address</div> | ||
| <div>Expires on</div> | ||
| <div></div> | ||
| </div> | ||
| {!tokenList || tokenList.length === 0 ? ( | ||
| <NoMatchingResults /> | ||
| ) : ( | ||
| tokenList.map((list, index) => ( | ||
| <div | ||
| key={`api_${index}`} | ||
| className="api-list-row flex-align-center fw-4 cn-9 fs-13 pr-20 pl-20" | ||
| style={{ height: '45px' }} | ||
| > | ||
| <button | ||
| type="button" | ||
| className="transparent cursor flex" | ||
| onClick={() => handleGenerateRowActionButton('edit', list.id)} | ||
| > | ||
| <Key | ||
| className={`api-key-icon icon-dim-20 ${ | ||
| isTokenExpired(list.expireAtInMs) ? 'api-key-expired-icon' : '' | ||
| }`} | ||
| /> | ||
| </button> | ||
| <div | ||
| className={`flexbox cb-5 cursor`} | ||
| onClick={() => handleGenerateRowActionButton('edit', list.id)} | ||
| > | ||
| {list.name} | ||
| </div> | ||
| <div className="ellipsis-right">{moment(list.lastUsedAt).format(MomentDateFormat)}</div> | ||
| <div>{list.lastUsedByIp}</div> | ||
| <div className={`${isTokenExpired(list.expireAtInMs) ? 'cr-5' : ''}`}> | ||
| {list.expireAtInMs === 0 ? ( | ||
| 'No expiration date' | ||
| ) : ( | ||
| <> | ||
| {isTokenExpired(list.expireAtInMs) ? 'Expired on ' : ''} | ||
| {moment(list.expireAtInMs).format(MomentDateFormat)} | ||
| </> | ||
| )} | ||
| </div> | ||
| <div className="api__row-actions flex"> | ||
| <button | ||
| type="button" | ||
| className="transparent mr-8 ml-8" | ||
| onClick={() => handleGenerateRowActionButton('edit', list.id)} | ||
| > | ||
| <Edit className="icon-dim-20" /> | ||
| </button> | ||
| <button | ||
| type="button" | ||
| className="transparent" | ||
| onClick={() => { | ||
| setSelectedToken(list) | ||
| setDeleteConfirmation(true) | ||
| }} | ||
| > | ||
| <Trash className="scn-6 icon-dim-20" /> | ||
| </button> | ||
| </div> | ||
| </div> | ||
| )) | ||
| )} | ||
| {showDeleteConfirmation && selectedToken && ( | ||
| <DeleteAPITokenModal | ||
| tokenData={selectedToken} | ||
| reload={reload} | ||
| setDeleteConfirmation={setDeleteConfirmation} | ||
| /> | ||
| )} | ||
| </div> | ||
| </div> | ||
| ) | ||
| } | ||
|
|
||
| export default APITokenList | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,215 @@ | ||
| import React, { Fragment, useEffect, useState } from 'react' | ||
| import './apiToken.scss' | ||
| import { ReactComponent as Search } from '../../assets/icons/ic-search.svg' | ||
| import { ReactComponent as Clear } from '../../assets/icons/ic-error.svg' | ||
| import { getGeneratedAPITokenList } from './service' | ||
| import { showError, Progressing, ErrorScreenManager, useAsync } from '../common' | ||
| import EmptyState from '../EmptyState/EmptyState' | ||
| import emptyGeneratToken from '../../assets/img/ic-empty-generate-token.png' | ||
| import { Redirect, Route, Switch, useHistory, useLocation, useRouteMatch } from 'react-router-dom' | ||
| import APITokenList from './APITokenList' | ||
| import CreateAPIToken from './CreateAPIToken' | ||
| import EditAPIToken from './EditAPIToken' | ||
| import { TokenListType, TokenResponseType } from './authorization.type' | ||
|
|
||
| function ApiTokens({ reloadLists }) { | ||
|
||
| const { path } = useRouteMatch() | ||
| const history = useHistory() | ||
| const { pathname } = useLocation() | ||
| const [searchText, setSearchText] = useState('') | ||
| const [searchApplied, setSearchApplied] = useState(false) | ||
| const [loader, setLoader] = useState(false) | ||
| const [tokenList, setTokenlist] = useState<TokenListType[]>(undefined) | ||
| const [filteredTokenList, setFilteredTokenList] = useState<TokenListType[]>(undefined) | ||
| const [noResults, setNoResults] = useState(false) | ||
| const [errorStatusCode, setErrorStatusCode] = useState(0) | ||
| const [showGenerateModal, setShowGenerateModal] = useState(false) | ||
| const [showRegenerateTokenModal, setShowRegenerateTokenModal] = useState(false) | ||
| const [copied, setCopied] = useState(false) | ||
| const [selectedExpirationDate, setSelectedExpirationDate] = useState<{ label: string; value: number }>({ | ||
| label: '30 days', | ||
| value: 30, | ||
| }) | ||
|
|
||
| const getData = (): void => { | ||
| setLoader(true) | ||
| getGeneratedAPITokenList() | ||
| .then((response) => { | ||
| if (response.result) { | ||
| const sortedResult = response.result.sort((a, b) => a['name'].localeCompare(b['name'])) | ||
| setTokenlist(sortedResult) | ||
| setFilteredTokenList(sortedResult) | ||
| } else { | ||
| setTokenlist([]) | ||
| setFilteredTokenList([]) | ||
| } | ||
| setLoader(false) | ||
| }) | ||
| .catch((error) => { | ||
| showError(error) | ||
| setErrorStatusCode(error.code) | ||
| setLoader(false) | ||
| }) | ||
| } | ||
|
|
||
| useEffect(() => { | ||
| // TODO: Revisit. Temp check | ||
| if ( | ||
| pathname.includes('/devtron-apps') || | ||
| pathname.includes('/helm-apps') || | ||
| pathname.includes('/chart-groups') | ||
| ) { | ||
| history.replace(pathname.split('/').slice(0, -1).join('/')) | ||
| } | ||
|
|
||
| getData() | ||
| }, []) | ||
|
|
||
| const handleFilterChanges = (_searchText: string): void => { | ||
| const _filteredData = tokenList.filter((token) => token.name.indexOf(_searchText) >= 0) | ||
| setFilteredTokenList(_filteredData) | ||
| setNoResults(_filteredData.length === 0) | ||
| } | ||
|
|
||
| const clearSearch = (): void => { | ||
| if (searchApplied) { | ||
| handleFilterChanges('') | ||
| setSearchApplied(false) | ||
| } | ||
| setSearchText('') | ||
| } | ||
|
|
||
| const handleFilterKeyPress = (event): void => { | ||
| const theKeyCode = event.key | ||
| if (theKeyCode === 'Enter') { | ||
| handleFilterChanges(event.target.value) | ||
| setSearchApplied(!!event.target.value) | ||
| } else if (theKeyCode === 'Backspace' && searchText.length === 1) { | ||
| clearSearch() | ||
| } | ||
| } | ||
|
|
||
| const [tokenResponse, setTokenResponse] = useState<TokenResponseType>({ | ||
| success: false, | ||
| token: '', | ||
| userId: 0, | ||
| userIdentifier: 'API-TOKEN:test', | ||
| }) | ||
|
|
||
| const renderSearchToken = () => { | ||
| return ( | ||
| <div className="flexbox content-space"> | ||
| <div className="search position-rel en-2 bw-1 br-4 h-32"> | ||
| <Search className="search__icon icon-dim-18" /> | ||
| <input | ||
| type="text" | ||
| placeholder="Search Token" | ||
| value={searchText} | ||
| className="search__input bcn-0" | ||
| onChange={(event) => { | ||
| setSearchText(event.target.value) | ||
| }} | ||
| onKeyDown={handleFilterKeyPress} | ||
| /> | ||
| {searchApplied && ( | ||
| <button className="search__clear-button" type="button" onClick={clearSearch}> | ||
| <Clear className="icon-dim-18 icon-n4 vertical-align-middle" /> | ||
| </button> | ||
| )} | ||
| </div> | ||
| </div> | ||
| ) | ||
| } | ||
|
|
||
| const handleActionButton = () => { | ||
| setShowGenerateModal(false) | ||
| setShowRegenerateTokenModal(false) | ||
| } | ||
|
|
||
| const renderAPITokenRoutes = (): JSX.Element => { | ||
| return ( | ||
| <Fragment> | ||
| <div className="api-token-container"> | ||
| <Switch> | ||
| <Route path={`${path}/list`}> | ||
| <APITokenList | ||
| tokenList={filteredTokenList} | ||
| renderSearchToken={renderSearchToken} | ||
| reload={getData} | ||
| /> | ||
| </Route> | ||
| <Route path={`${path}/create`}> | ||
| <CreateAPIToken | ||
| setShowGenerateModal={setShowGenerateModal} | ||
| showGenerateModal={showGenerateModal} | ||
| handleGenerateTokenActionButton={handleActionButton} | ||
| setSelectedExpirationDate={setSelectedExpirationDate} | ||
| selectedExpirationDate={selectedExpirationDate} | ||
| tokenResponse={tokenResponse} | ||
| setTokenResponse={setTokenResponse} | ||
| reload={getData} | ||
| /> | ||
| </Route> | ||
| <Route path={`${path}/edit/:id`}> | ||
| <EditAPIToken | ||
| handleRegenerateActionButton={handleActionButton} | ||
| setShowRegeneratedModal={setShowRegenerateTokenModal} | ||
| showRegeneratedModal={showRegenerateTokenModal} | ||
| setSelectedExpirationDate={setSelectedExpirationDate} | ||
| selectedExpirationDate={selectedExpirationDate} | ||
| tokenList={tokenList} | ||
| setCopied={setCopied} | ||
| copied={copied} | ||
| reload={getData} | ||
| /> | ||
| </Route> | ||
| <Redirect to={`${path}/list`} /> | ||
| </Switch> | ||
| </div> | ||
| </Fragment> | ||
| ) | ||
| } | ||
|
|
||
| const renderEmptyState = (): JSX.Element => { | ||
| return ( | ||
| <div className="flex column h-100"> | ||
| <EmptyState> | ||
| <EmptyState.Image> | ||
| <img src={emptyGeneratToken} alt="Empty api token links" /> | ||
| </EmptyState.Image> | ||
| <EmptyState.Title> | ||
| <h4 className="title">Generate a token to access the Devtron API</h4> | ||
| </EmptyState.Title> | ||
| <EmptyState.Subtitle> | ||
| API tokens function like ordinary OAuth access tokens. They can be used instead of a password | ||
| for Git over HTTPS, or can be used to authenticate to the API over Basic Authentication. | ||
| </EmptyState.Subtitle> | ||
| <EmptyState.Button> | ||
| <button className="flex cta h-32" onClick={() => history.push(`${path}/create`)}> | ||
|
||
| Generate new token | ||
| </button> | ||
| </EmptyState.Button> | ||
| </EmptyState> | ||
| </div> | ||
| ) | ||
| } | ||
|
|
||
| if (loader) { | ||
| return <Progressing pageLoader /> | ||
| } else if (errorStatusCode > 0) { | ||
| return ( | ||
| <div className="error-screen-wrapper flex column h-100"> | ||
| <ErrorScreenManager | ||
| code={errorStatusCode} | ||
| subtitle="Information on this page is available only to superadmin users." | ||
| /> | ||
| </div> | ||
| ) | ||
| } else if (!pathname.includes('/create') && (!tokenList || tokenList.length === 0)) { | ||
| return renderEmptyState() | ||
| } | ||
|
|
||
| return renderAPITokenRoutes() | ||
| } | ||
|
|
||
| export default ApiTokens | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We should create methods instead of doing it inline