Skip to content
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

API Token use cases integration for account page #531

Merged
merged 10 commits into from
Nov 8, 2024
4 changes: 3 additions & 1 deletion src/sections/account/Account.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Tabs } from '@iqss/dataverse-design-system'
import { AccountHelper, AccountPanelTabKey } from './AccountHelper'
import { ApiTokenSection } from './api-token-section/ApiTokenSection'
import styles from './Account.module.scss'
import { ApiTokenInfoJSDataverseRepository } from '@/users/infrastructure/repositories/ApiTokenInfoJSDataverseRepository'
ChengShi-1 marked this conversation as resolved.
Show resolved Hide resolved

const tabsKeys = AccountHelper.ACCOUNT_PANEL_TABS_KEYS

Expand All @@ -12,6 +13,7 @@ interface AccountProps {

export const Account = ({ defaultActiveTabKey }: AccountProps) => {
const { t } = useTranslation('account')
const repository = new ApiTokenInfoJSDataverseRepository()
ChengShi-1 marked this conversation as resolved.
Show resolved Hide resolved

return (
<section>
Expand All @@ -34,7 +36,7 @@ export const Account = ({ defaultActiveTabKey }: AccountProps) => {
</Tabs.Tab>
<Tabs.Tab eventKey={tabsKeys.apiToken} title={t('tabs.apiToken')}>
<div className={styles['tab-container']}>
<ApiTokenSection />
<ApiTokenSection repository={repository} />
</div>
</Tabs.Tab>
</Tabs>
Expand Down
85 changes: 73 additions & 12 deletions src/sections/account/api-token-section/ApiTokenSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,80 @@ import { Trans, useTranslation } from 'react-i18next'
import { Button } from '@iqss/dataverse-design-system'
import accountStyles from '../Account.module.scss'
import styles from './ApiTokenSection.module.scss'
import { useEffect, useState } from 'react'
import { Alert } from '@iqss/dataverse-design-system'
import { TokenInfo } from '@/users/domain/models/TokenInfo'
import { ApiTokenInfoRepository } from '@/users/domain/repositories/ApiTokenInfoRepository'
import ApiTokenSectionSkeleton from './ApiTokenSectionSkeleton'
import { useGetApiToken } from './useGetCurrentApiToken'
import { useRecreateApiToken } from './useRecreateApiToken'
import { useRevokeApiToken } from './useRevokeApiToken'
ChengShi-1 marked this conversation as resolved.
Show resolved Hide resolved
interface ApiTokenSectionProps {
repository: ApiTokenInfoRepository
}

export const ApiTokenSection = () => {
export const ApiTokenSection = ({ repository }: ApiTokenSectionProps) => {
const { t } = useTranslation('account', { keyPrefix: 'apiToken' })
const [currentApiTokenInfo, setCurrentApiTokenInfo] = useState<TokenInfo>()

const { error, apiTokenInfo, isLoading } = useGetApiToken(repository)

const getError =
error !== 'There was an error when reading the resource. Reason was: [404] Token not found.'
g-saracca marked this conversation as resolved.
Show resolved Hide resolved
? error
: null

useEffect(() => {
setCurrentApiTokenInfo(apiTokenInfo)
}, [apiTokenInfo])

const {
initiateRecreateToken,
isRecreating,
error: recreatingError,
apiTokenInfo: updatedTokenInfo
} = useRecreateApiToken(repository)

useEffect(() => {
if (updatedTokenInfo) {
setCurrentApiTokenInfo(updatedTokenInfo)
}
}, [updatedTokenInfo])

const handleCreateToken = () => {
initiateRecreateToken()
}

// TODO: When we have the use cases we need to mock stub to unit test this with or without token
const apiToken = '999fff-666rrr-this-is-not-a-real-token-123456'
const expirationDate = '2025-09-04'
const { revokeToken, isRevoking, error: revokingError } = useRevokeApiToken(repository)

const handleRevokeToken = async () => {
await revokeToken()
setCurrentApiTokenInfo({
apiToken: '',
expirationDate: ''
})
}

const copyToClipboard = () => {
navigator.clipboard.writeText(apiToken).catch(
navigator.clipboard.writeText(apiTokenInfo.apiToken).catch(
/* istanbul ignore next */ (error) => {
console.error('Failed to copy text:', error)
}
)
}

if (isLoading || isRecreating || isRevoking) {
return <ApiTokenSectionSkeleton data-testid="loadingSkeleton" />
}

if (getError || recreatingError || revokingError) {
return (
<Alert variant="danger" dismissible={false}>
{getError || recreatingError || revokingError}
</Alert>
)
}

return (
<>
<p className={accountStyles['helper-text']}>
Expand All @@ -35,22 +93,25 @@ export const ApiTokenSection = () => {
}}
/>
</p>
{apiToken ? (
{currentApiTokenInfo?.apiToken ? (
<>
<p className={styles['exp-date']}>
{t('expirationDate')} <time dateTime={expirationDate}>{expirationDate}</time>
{t('expirationDate')}{' '}
<time data-testid="expiration-date" dateTime={currentApiTokenInfo.expirationDate}>
{currentApiTokenInfo.expirationDate}
</time>
</p>
<div className={styles['api-token']}>
<code data-testid="api-token">{apiToken}</code>
<code data-testid="api-token">{currentApiTokenInfo.apiToken}</code>
</div>
<div className={styles['btns-wrapper']} role="group">
<Button variant="secondary" onClick={copyToClipboard}>
{t('copyToClipboard')}
</Button>
<Button variant="secondary" disabled>
<Button variant="secondary" onClick={handleCreateToken}>
{t('recreateToken')}
</Button>
<Button variant="secondary" disabled>
<Button variant="secondary" onClick={handleRevokeToken}>
{t('revokeToken')}
</Button>
</div>
Expand All @@ -60,8 +121,8 @@ export const ApiTokenSection = () => {
<div className={styles['api-token']}>
<code data-testid="api-token">{t('notCreatedApiToken')}</code>
</div>
<div className={styles['btns-wrapper']} role="group">
<Button variant="secondary" disabled>
<div className={styles['btns-wrapper']} data-testid="noApiToken" role="group">
<Button data-testid="createApi" variant="secondary" onClick={handleCreateToken}>
{t('createToken')}
</Button>
</div>
Expand Down
51 changes: 51 additions & 0 deletions src/sections/account/api-token-section/ApiTokenSectionSkeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton'
import 'react-loading-skeleton/dist/skeleton.css'
ChengShi-1 marked this conversation as resolved.
Show resolved Hide resolved
import { Trans, useTranslation } from 'react-i18next'
import accountStyles from '../Account.module.scss'
import { Button } from '@iqss/dataverse-design-system'
import styles from './ApiTokenSection.module.scss'
ChengShi-1 marked this conversation as resolved.
Show resolved Hide resolved

const ApiTokenSectionSkeleton = () => {
const { t } = useTranslation('account', { keyPrefix: 'apiToken' })

return (
<>
<p className={accountStyles['helper-text']}>
<Trans
t={t}
i18nKey="helperText"
components={{
anchor: (
<a
href="http://guides.dataverse.org/en/latest/api"
target="_blank"
rel="noreferrer"
/>
)
}}
/>
</p>
<SkeletonTheme>
<div data-testid="loadingSkeleton">
<p className={styles['exp-date']}>
{t('expirationDate')}{' '}
<time data-testid="expiration-date">
<Skeleton width={100} />
</time>
</p>
<div className={styles['api-token']}>
<code data-testid="api-token">
<Skeleton width={350} />
</code>
</div>
<div className={styles['btns-wrapper']} role="group">
<Button variant="secondary">{t('copyToClipboard')}</Button>
<Button variant="secondary">{t('recreateToken')}</Button>
<Button variant="secondary">{t('revokeToken')}</Button>
</div>
</div>
</SkeletonTheme>
</>
)
}
export default ApiTokenSectionSkeleton
43 changes: 43 additions & 0 deletions src/sections/account/api-token-section/useGetCurrentApiToken.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { useState, useEffect, useCallback } from 'react'
import { TokenInfo } from '@/users/domain/models/TokenInfo'
import { ApiTokenInfoRepository } from '@/users/domain/repositories/ApiTokenInfoRepository'
import { getCurrentApiToken } from '@/users/domain/useCases/getCurrentApiToken'

interface UseGetApiTokenResult {
apiTokenInfo: TokenInfo
isLoading: boolean
error: string | null
}

export const useGetApiToken = (repository: ApiTokenInfoRepository): UseGetApiTokenResult => {
const [apiTokenInfo, setApiTokenInfo] = useState<TokenInfo>({
apiToken: '',
expirationDate: ''
})
const [isLoading, setIsLoading] = useState<boolean>(true)
const [error, setError] = useState<string | null>(null)

const fetchTokenInfo = useCallback(async () => {
try {
setIsLoading(true)
const tokenInfo = await getCurrentApiToken(repository)
setApiTokenInfo({
apiToken: tokenInfo.apiToken,
expirationDate: tokenInfo.expirationDate
})
setError(null)
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch API token.'
console.error(errorMessage)
setError(errorMessage)
} finally {
setIsLoading(false)
}
}, [repository])

useEffect(() => {
void fetchTokenInfo()
}, [fetchTokenInfo])

return { isLoading, error, apiTokenInfo }
}
48 changes: 48 additions & 0 deletions src/sections/account/api-token-section/useRecreateApiToken.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { useState, useEffect } from 'react'
import { recreateApiToken } from '@/users/domain/useCases/recreateApiToken'
import { TokenInfo } from '@/users/domain/models/TokenInfo'
import { ApiTokenInfoRepository } from '@/users/domain/repositories/ApiTokenInfoRepository'

interface UseRecreateApiTokenResult {
initiateRecreateToken: () => void
isRecreating: boolean
error: string | null
apiTokenInfo: TokenInfo | null
}

export const useRecreateApiToken = (
repository: ApiTokenInfoRepository
): UseRecreateApiTokenResult => {
const [isRecreating, setIsRecreating] = useState<boolean>(false)
const [error, setError] = useState<string | null>(null)
const [apiTokenInfo, setApiTokenInfo] = useState<TokenInfo | null>(null)
const [shouldRecreate, setShouldRecreate] = useState<boolean>(false)

const initiateRecreateToken = () => {
setShouldRecreate(true)
}

useEffect(() => {
const recreateToken = async () => {
setIsRecreating(true)
setError(null)

try {
const newTokenInfo = await recreateApiToken(repository)
setApiTokenInfo(newTokenInfo)
} catch (err) {
console.error('Error recreating token:', err)
setError('Failed to recreate API token.')
} finally {
setIsRecreating(false)
setShouldRecreate(false)
}
}

if (shouldRecreate) {
void recreateToken()
}
}, [shouldRecreate, repository])
ChengShi-1 marked this conversation as resolved.
Show resolved Hide resolved

return { initiateRecreateToken, isRecreating, error, apiTokenInfo }
}
30 changes: 30 additions & 0 deletions src/sections/account/api-token-section/useRevokeApiToken.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { useState, useCallback } from 'react'
import { revokeApiToken } from '@/users/domain/useCases/revokeApiToken'
import { ApiTokenInfoRepository } from '@/users/domain/repositories/ApiTokenInfoRepository'

interface UseRevokeApiTokenResult {
revokeToken: () => Promise<void>
isRevoking: boolean
error: string | null
}

export const useRevokeApiToken = (repository: ApiTokenInfoRepository): UseRevokeApiTokenResult => {
const [isRevoking, setIsRevoking] = useState<boolean>(false)
const [error, setError] = useState<string | null>(null)

const revokeToken = useCallback(async () => {
setIsRevoking(true)
setError(null)

try {
await revokeApiToken(repository)
} catch (err) {
console.error('There was an error revoking Api token:', err)
setError('Failed to revoke API token.')
ChengShi-1 marked this conversation as resolved.
Show resolved Hide resolved
} finally {
setIsRevoking(false)
}
}, [repository])

return { revokeToken, isRevoking, error }
}
4 changes: 4 additions & 0 deletions src/users/domain/models/TokenInfo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export interface TokenInfo {
apiToken: string
expirationDate: string
ChengShi-1 marked this conversation as resolved.
Show resolved Hide resolved
}
7 changes: 7 additions & 0 deletions src/users/domain/repositories/ApiTokenInfoRepository.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { TokenInfo } from '../.././domain/models/TokenInfo'

export interface ApiTokenInfoRepository {
getCurrentApiToken(): Promise<TokenInfo>
recreateApiToken(): Promise<TokenInfo>
deleteApiToken(): Promise<void>
}
6 changes: 6 additions & 0 deletions src/users/domain/useCases/getCurrentApiToken.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { TokenInfo } from '../models/TokenInfo'
import { ApiTokenInfoRepository } from '../repositories/ApiTokenInfoRepository'

export function getCurrentApiToken(apiTokenRepository: ApiTokenInfoRepository): Promise<TokenInfo> {
return apiTokenRepository.getCurrentApiToken()
}
6 changes: 6 additions & 0 deletions src/users/domain/useCases/recreateApiToken.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { TokenInfo } from '../models/TokenInfo'
import { ApiTokenInfoRepository } from '../repositories/ApiTokenInfoRepository'

export function recreateApiToken(apiTokenRepository: ApiTokenInfoRepository): Promise<TokenInfo> {
return apiTokenRepository.recreateApiToken()
}
5 changes: 5 additions & 0 deletions src/users/domain/useCases/revokeApiToken.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { ApiTokenInfoRepository } from '../repositories/ApiTokenInfoRepository'

export function revokeApiToken(apiTokenRepository: ApiTokenInfoRepository): Promise<void> {
return apiTokenRepository.deleteApiToken()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { TokenInfo } from '../../domain/models/TokenInfo'
ChengShi-1 marked this conversation as resolved.
Show resolved Hide resolved
import { ApiTokenInfoRepository } from '../../domain/repositories/ApiTokenInfoRepository'
import { DateHelper } from '@/shared/helpers/DateHelper'
import {
getCurrentApiToken,
recreateCurrentApiToken,
deleteCurrentApiToken
} from '@iqss/dataverse-client-javascript'

interface ApiTokenInfoPayload {
apiToken: string
expirationDate: Date
}

export class ApiTokenInfoJSDataverseRepository implements ApiTokenInfoRepository {
getCurrentApiToken(): Promise<TokenInfo> {
return getCurrentApiToken.execute().then((apiTokenInfo: ApiTokenInfoPayload) => {
return {
apiToken: apiTokenInfo.apiToken,
expirationDate: DateHelper.toISO8601Format(apiTokenInfo.expirationDate)
}
})
}

recreateApiToken(): Promise<TokenInfo> {
return recreateCurrentApiToken.execute().then((apiTokenInfo: ApiTokenInfoPayload) => {
return {
apiToken: apiTokenInfo.apiToken,
expirationDate: DateHelper.toISO8601Format(apiTokenInfo.expirationDate)
}
})
}

deleteApiToken(): Promise<void> {
return deleteCurrentApiToken.execute()
}
}
Loading
Loading