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

const tabsKeys = AccountHelper.ACCOUNT_PANEL_TABS_KEYS
Expand Down Expand Up @@ -33,9 +33,7 @@ export const Account = ({ defaultActiveTabKey }: AccountProps) => {
<div className={styles['tab-container']}></div>
</Tabs.Tab>
<Tabs.Tab eventKey={tabsKeys.apiToken} title={t('tabs.apiToken')}>
<div className={styles['tab-container']}>
<ApiTokenSection />
</div>
<div className={styles['tab-container']}>{createUserRepository.create()}</div>
</Tabs.Tab>
</Tabs>
</section>
Expand Down
89 changes: 77 additions & 12 deletions src/sections/account/api-token-section/ApiTokenSection.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,83 @@
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, 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 {
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 +95,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 +125,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>
</>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { ReactElement } from 'react'
import { ApiTokenSection } from './ApiTokenSection'
import { UserJSDataverseRepository } from '@/users/infrastructure/repositories/UserJSDataverseRepository'

const userRepository = new UserJSDataverseRepository()

export class createUserRepository {
g-saracca marked this conversation as resolved.
Show resolved Hide resolved
static create(): ReactElement {
return <CreateUserRepository />
}
}

function CreateUserRepository() {
return <ApiTokenSection repository={userRepository} />
}
45 changes: 45 additions & 0 deletions src/sections/account/api-token-section/useGetCurrentApiToken.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
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)
} 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 }
}
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: Date
}
4 changes: 4 additions & 0 deletions src/users/domain/repositories/UserRepository.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { User } from '../models/User'
import { TokenInfo } from '../.././domain/models/TokenInfo'

export interface UserRepository {
getAuthenticated: () => Promise<User>
removeAuthenticated: () => Promise<void>
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 { UserRepository } from '../repositories/UserRepository'

export function getCurrentApiToken(userRepository: UserRepository): Promise<TokenInfo> {
return userRepository.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 { UserRepository } from '../repositories/UserRepository'

export function recreateApiToken(userRepository: UserRepository): Promise<TokenInfo> {
return userRepository.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 { UserRepository } from '../repositories/UserRepository'

export function revokeApiToken(userRepository: UserRepository): Promise<void> {
return userRepository.deleteApiToken()
}
Loading
Loading