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
6 changes: 4 additions & 2 deletions src/sections/account/Account.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import { useTranslation } from 'react-i18next'
import { Tabs } from '@iqss/dataverse-design-system'
import { AccountHelper, AccountPanelTabKey } from './AccountHelper'
import { UserJSDataverseRepository } from '@/users/infrastructure/repositories/UserJSDataverseRepository'
import { ApiTokenSection } from './api-token-section/ApiTokenSection'
import styles from './Account.module.scss'

const tabsKeys = AccountHelper.ACCOUNT_PANEL_TABS_KEYS

interface AccountProps {
defaultActiveTabKey: AccountPanelTabKey
userRepository: UserJSDataverseRepository
}

export const Account = ({ defaultActiveTabKey }: AccountProps) => {
export const Account = ({ defaultActiveTabKey, userRepository }: AccountProps) => {
const { t } = useTranslation('account')

return (
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={userRepository} />
</div>
</Tabs.Tab>
</Tabs>
Expand Down
5 changes: 4 additions & 1 deletion src/sections/account/AccountFactory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import { ReactElement } from 'react'
import { useSearchParams } from 'react-router-dom'
import { AccountHelper } from './AccountHelper'
import { Account } from './Account'
import { UserJSDataverseRepository } from '@/users/infrastructure/repositories/UserJSDataverseRepository'

const userRepository = new UserJSDataverseRepository()

export class AccountFactory {
static create(): ReactElement {
Expand All @@ -13,5 +16,5 @@ function AccountWithSearchParams() {
const [searchParams] = useSearchParams()
const defaultActiveTabKey = AccountHelper.defineSelectedTabKey(searchParams)

return <Account defaultActiveTabKey={defaultActiveTabKey} />
return <Account defaultActiveTabKey={defaultActiveTabKey} userRepository={userRepository} />
}
84 changes: 72 additions & 12 deletions src/sections/account/api-token-section/ApiTokenSection.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,78 @@
import { Trans, useTranslation } from 'react-i18next'
import { useEffect, useState } from 'react'
import { useGetApiToken } from './useGetCurrentApiToken'
import { useRecreateApiToken } from './useRecreateApiToken'
import { useRevokeApiToken } from './useRevokeApiToken'
ChengShi-1 marked this conversation as resolved.
Show resolved Hide resolved
import { ApiTokenSectionSkeleton } from './ApiTokenSectionSkeleton'
import { TokenInfo } from '@/users/domain/models/TokenInfo'
import { DateHelper } from '@/shared/helpers/DateHelper'
import { UserRepository } from '@/users/domain/repositories/UserRepository'
import { Button } from '@iqss/dataverse-design-system'
import { Alert } from '@iqss/dataverse-design-system'
import accountStyles from '../Account.module.scss'
import styles from './ApiTokenSection.module.scss'

export const ApiTokenSection = () => {
interface ApiTokenSectionProps {
repository: UserRepository
}

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

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

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

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

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

const handleCreateToken = () => {
void recreateToken()
}

// 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: new Date(0)
})
}

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 +90,27 @@ 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={DateHelper.toISO8601Format(currentApiTokenInfo.expirationDate)}>
{DateHelper.toISO8601Format(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 +120,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
49 changes: 49 additions & 0 deletions src/sections/account/api-token-section/ApiTokenSectionSkeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { Trans, useTranslation } from 'react-i18next'
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton'
import { Button } from '@iqss/dataverse-design-system'
import accountStyles from '../Account.module.scss'
import styles from './ApiTokenSection.module.scss'
ChengShi-1 marked this conversation as resolved.
Show resolved Hide resolved

export 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>
</>
)
}
49 changes: 49 additions & 0 deletions src/sections/account/api-token-section/useGetCurrentApiToken.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { useState, useEffect, useCallback } from 'react'
import { TokenInfo } from '@/users/domain/models/TokenInfo'
import { UserRepository } from '@/users/domain/repositories/UserRepository'
import { getCurrentApiToken } from '@/users/domain/useCases/getCurrentApiToken'

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

export const useGetApiToken = (repository: UserRepository): UseGetApiTokenResult => {
const [apiTokenInfo, setApiTokenInfo] = useState<TokenInfo>({
apiToken: '',
expirationDate: new Date(0)
})
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
? err.message
: 'Something went wrong getting the current api token. Try again later.'
setError(
errorMessage ===
'There was an error when reading the resource. Reason was: [404] Token not found.'
? null
: errorMessage
)
} finally {
setIsLoading(false)
}
}, [repository])

useEffect(() => {
void fetchTokenInfo()
}, [fetchTokenInfo])
return { isLoading, error, apiTokenInfo }
}
36 changes: 36 additions & 0 deletions src/sections/account/api-token-section/useRecreateApiToken.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { useState } from 'react'
import { recreateApiToken } from '@/users/domain/useCases/recreateApiToken'
import { TokenInfo } from '@/users/domain/models/TokenInfo'
import { UserRepository } from '@/users/domain/repositories/UserRepository'

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

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

const recreateToken = async () => {
setIsRecreating(true)
setError(null)
try {
const newTokenInfo = await recreateApiToken(repository)
setApiTokenInfo(newTokenInfo)
} catch (err) {
const errorMessage =
err instanceof Error && err.message
? err.message
: 'Something went wrong creating the api token. Try again later.'
setError(errorMessage)
} finally {
setIsRecreating(false)
}
}

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

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

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

const revokeToken = async () => {
setIsRevoking(true)
setError(null)
try {
await revokeApiToken(repository)
} catch (err) {
const errorMessage =
err instanceof Error && err.message
? err.message
: 'Something went wrong revoking the api token. Try again later.'
setError(errorMessage)
} finally {
setIsRevoking(false)
}
}

return { revokeToken, isRevoking, error }
}
Loading
Loading