Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,15 @@ This changelog follows the principles of [Keep a Changelog](https://keepachangel

### Added

- Users: Added `signUp` use case for creating a builtin user account without authentication (`POST /users/signup`).
- Users: Added `getOrcidSignUpAuthorizationUrl` and `connectOrcidForSignUp` use cases for authenticating an ORCID iD while signing up.
- Users: Added optional `authenticatedOrcid` to `AuthenticatedUser`.
- Core: `ReadError` and `WriteError` now expose the response's `statusCode` and, when the API returns them, per-field `fieldErrors`.

### Changed

- Core: `ApiRepository.doPost` accepts an optional `authRequired` argument (default `true`) to send a request without the configured credentials.

### Fixed

### Removed
Expand Down
106 changes: 106 additions & 0 deletions docs/useCases.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,10 +109,13 @@ The different use cases currently available in the package are classified below,
- [Users read use cases](#users-read-use-cases)
- [Get Current Authenticated User](#get-current-authenticated-user)
- [Get Current API Token](#get-current-api-token)
- [Get ORCID Sign-Up Authorization URL](#get-orcid-sign-up-authorization-url)
- [Users write use cases](#users-write-use-cases)
- [Delete Current API Token](#delete-current-api-token)
- [Recreate Current API Token](#recreate-current-api-token)
- [Register User](#register-user)
- [Sign Up](#sign-up)
- [Connect ORCID For Sign-Up](#connect-orcid-for-sign-up)
- [Roles](#Roles)
- [Roles read use cases](#roles-read-use-cases)
- [Get User Selectable Roles](#get-user-selectable-roles)
Expand Down Expand Up @@ -2747,6 +2750,8 @@ There is a third optional parameter called `datasetType` which will include addi

Returns the current [AuthenticatedUser](../src/users/domain/models/AuthenticatedUser.ts) corresponding to the authentication mechanism provided through `ApiConfig`.

The `authenticatedOrcid` property holds the URL of the ORCID iD the user has authenticated and linked to their account, if any.

##### Example call:

```typescript
Expand Down Expand Up @@ -2783,6 +2788,32 @@ getCurrentApiToken.execute().then((apiTokenInfo: ApiTokenInfo) => {

_See [use case](../src/users/domain/useCases/GetCurrentApiToken.ts) implementation_.

#### Get ORCID Sign-Up Authorization URL

Returns the ORCID authorization URL that sends a new user to ORCID to authenticate their ORCID iD while signing up. Send the user's browser to this URL; after they sign in, ORCID redirects back to `redirectUri` with `code` and `state` query parameters, which are then passed to [Connect ORCID For Sign-Up](#connect-orcid-for-sign-up).

No credentials are sent, so this works for anonymous users. The installation needs an ORCID OAuth2 provider (it can be disabled for login), and `redirectUri` must be listed in its `dataverse.api.signup.orcid-redirect-uris` setting.

##### Example call:

```typescript
import { getOrcidSignUpAuthorizationUrl } from '@iqss/dataverse-client-javascript'

/* ... */

const redirectUri = 'https://dataverse.example.edu/spa/sign-up'

getOrcidSignUpAuthorizationUrl.execute(redirectUri).then((authorizationUrl: string) => {
window.location.assign(authorizationUrl)
})

/* ... */
```

_See [use case](../src/users/domain/useCases/GetOrcidSignUpAuthorizationUrl.ts) implementation_.

The use case throws a `ReadError` with `statusCode` 404 when no ORCID provider is configured, and 400 when the redirect URI is not allowed.

### Users write use cases

### Delete Current API Token
Expand Down Expand Up @@ -2851,6 +2882,81 @@ registerUser.execute(userDTO)

_See [use case](../src/users/domain/useCases/RegisterUser.ts) implementation_.

### Sign Up

Creates a new builtin user account, given a [SignUpDTO](../src/users/domain/dtos/SignUpDTO.ts). No credentials are sent, so this works for anonymous users. The installation must enable the `api-builtin-user-signup` feature flag and allow sign-up (`:AllowSignUp`).

To save an authenticated ORCID iD to the new account, include the `orcidToken` returned by [Connect ORCID For Sign-Up](#connect-orcid-for-sign-up).

##### Example call:

```typescript
import { signUp } from '@iqss/dataverse-client-javascript'

/* ... */

const signUpDTO: SignUpDTO = {
username: 'alovelace',
firstName: 'Ada',
lastName: 'Lovelace',
emailAddress: 'ada@example.edu',
password: 'Str0ngPassw0rd',
affiliation: 'Harvard University',
termsAccepted: true
}

signUp
.execute(signUpDTO)
.then((result: SignUpResult) => {
if (result.status === 'created') {
/* the account exists; result.identifier is e.g. '@alovelace' */
} else {
/* status 'pending': the installation sent a verification email first */
}
})
.catch((error: WriteError) => {
/* error.fieldErrors maps invalid fields to messages, e.g. { username: 'This username is already taken.' } */
})

/* ... */
```

_See [use case](../src/users/domain/useCases/SignUp.ts) implementation_.

The [SignUpResult](../src/users/domain/models/SignUpResult.ts) is `{ status: 'created', identifier }` when the account was created right away, or `{ status: 'pending' }` when the installation requires email verification before creating it.

### Connect ORCID For Sign-Up

Exchanges the `code` and `state` that ORCID returns to the redirect URI for the user's authenticated ORCID iD, given an [OrcidSignUpCallbackDTO](../src/users/domain/dtos/OrcidSignUpCallbackDTO.ts). Use the same `redirectUri` that was passed to [Get ORCID Sign-Up Authorization URL](#get-orcid-sign-up-authorization-url). No credentials are sent.

Returns an [OrcidSignUpConnection](../src/users/domain/models/OrcidSignUpConnection.ts) with the ORCID iD, any details the user shares on their ORCID record (useful to pre-fill a sign-up form), and an `orcidToken` to include in [Sign Up](#sign-up). The token is valid for 30 minutes.

##### Example call:

```typescript
import { connectOrcidForSignUp } from '@iqss/dataverse-client-javascript'

/* ... */

const params = new URLSearchParams(window.location.search)

connectOrcidForSignUp
.execute({
code: params.get('code') as string,
state: params.get('state') as string,
redirectUri: 'https://dataverse.example.edu/spa/sign-up'
})
.then((connection: OrcidSignUpConnection) => {
/* connection.orcid, connection.firstName, ..., connection.orcidToken */
})

/* ... */
```

_See [use case](../src/users/domain/useCases/ConnectOrcidForSignUp.ts) implementation_.

The use case throws a `WriteError` with `statusCode` 409 when the ORCID iD is already linked to another account, and 400 when the state is invalid or expired.

## Roles

### Get User Selectable Roles
Expand Down
6 changes: 3 additions & 3 deletions src/core/domain/repositories/ReadError.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { RepositoryError } from './RepositoryError'
import { RepositoryError, RepositoryErrorDetails } from './RepositoryError'

export class ReadError extends RepositoryError {
constructor(reason?: string) {
super('There was an error when reading the resource.', reason)
constructor(reason?: string, details?: RepositoryErrorDetails) {
super('There was an error when reading the resource.', reason, details)
}
}
12 changes: 11 additions & 1 deletion src/core/domain/repositories/RepositoryError.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,18 @@
export interface RepositoryErrorDetails {
statusCode?: number
fieldErrors?: Record<string, string>
}

export abstract class RepositoryError extends Error {
constructor(message: string, reason?: string) {
readonly statusCode?: number
readonly fieldErrors?: Record<string, string>

constructor(message: string, reason?: string, details?: RepositoryErrorDetails) {
if (reason) {
message += ` Reason was: ${reason}`
}
super(message)
this.statusCode = details?.statusCode
this.fieldErrors = details?.fieldErrors
}
}
6 changes: 3 additions & 3 deletions src/core/domain/repositories/WriteError.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { RepositoryError } from './RepositoryError'
import { RepositoryError, RepositoryErrorDetails } from './RepositoryError'

export class WriteError extends RepositoryError {
constructor(reason?: string) {
super('There was an error when writing the resource.', reason)
constructor(reason?: string, details?: RepositoryErrorDetails) {
super('There was an error when writing the resource.', reason, details)
}
}
1 change: 1 addition & 0 deletions src/core/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export { ReadError } from './domain/repositories/ReadError'
export { WriteError } from './domain/repositories/WriteError'
export { RepositoryErrorDetails } from './domain/repositories/RepositoryError'
export { ApiConfig } from './infra/repositories/ApiConfig'
export { DvObjectOwnerNode, DvObjectType } from './domain/models/DvObjectOwnerNode'
export { PublicationStatus } from './domain/models/PublicationStatus'
Expand Down
26 changes: 19 additions & 7 deletions src/core/infra/repositories/ApiRepository.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import axios, { AxiosResponse } from 'axios'
import { ReadError } from '../../domain/repositories/ReadError'
import { WriteError } from '../../domain/repositories/WriteError'
import { RepositoryErrorDetails } from '../../domain/repositories/RepositoryError'
import { buildRequestConfig, buildRequestUrl } from './apiConfigBuilders'
import { ApiConstants } from './ApiConstants'

Expand All @@ -14,17 +15,18 @@ export abstract class ApiRepository {
.get(buildRequestUrl(apiEndpoint), buildRequestConfig(authRequired, queryParams))
.then((response) => response)
.catch((error) => {
throw new ReadError(this.buildErrorMessage(error))
throw new ReadError(this.buildErrorMessage(error), this.buildErrorDetails(error))
})
}

public async doPost(
apiEndpoint: string,
data: string | object | boolean,
queryParams: object = {},
contentType: string = ApiConstants.CONTENT_TYPE_APPLICATION_JSON
contentType: string = ApiConstants.CONTENT_TYPE_APPLICATION_JSON,
authRequired = true
): Promise<AxiosResponse> {
return await this.doRequest('post', apiEndpoint, data, queryParams, contentType)
return await this.doRequest('post', apiEndpoint, data, queryParams, contentType, authRequired)
}

public async doPut(
Expand All @@ -41,7 +43,7 @@ export abstract class ApiRepository {
.delete(buildRequestUrl(apiEndpoint), buildRequestConfig(true, queryParams))
.then((response) => response)
.catch((error) => {
throw new WriteError(this.buildErrorMessage(error))
throw new WriteError(this.buildErrorMessage(error), this.buildErrorDetails(error))
})
}

Expand All @@ -59,6 +61,15 @@ export abstract class ApiRepository {
: `/${resourceName}${operationSegment}`
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
private buildErrorDetails(error: any): RepositoryErrorDetails {
const fieldErrors = error?.response?.data?.fieldErrors
return {
statusCode: error?.response?.status,
fieldErrors: fieldErrors && typeof fieldErrors === 'object' ? fieldErrors : undefined
}
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
private buildErrorMessage(error: any): string {
const status =
Expand All @@ -72,7 +83,8 @@ export abstract class ApiRepository {
apiEndpoint: string,
data: string | object | boolean,
queryParams: object = {},
contentType: string = ApiConstants.CONTENT_TYPE_APPLICATION_JSON
contentType: string = ApiConstants.CONTENT_TYPE_APPLICATION_JSON,
authRequired = true
): Promise<AxiosResponse> {
let requestData = data

Expand All @@ -83,13 +95,13 @@ export abstract class ApiRepository {
}

const requestUrl = buildRequestUrl(apiEndpoint)
const requestConfig = buildRequestConfig(true, queryParams, contentType)
const requestConfig = buildRequestConfig(authRequired, queryParams, contentType)

try {
const response = await axios[method](requestUrl, requestData, requestConfig)
return response
} catch (error) {
throw new WriteError(this.buildErrorMessage(error))
throw new WriteError(this.buildErrorMessage(error), this.buildErrorDetails(error))
}
}
}
5 changes: 5 additions & 0 deletions src/users/domain/dtos/OrcidSignUpCallbackDTO.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export interface OrcidSignUpCallbackDTO {
code: string
state: string
redirectUri: string
}
11 changes: 11 additions & 0 deletions src/users/domain/dtos/SignUpDTO.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export interface SignUpDTO {
username: string
firstName: string
lastName: string
emailAddress: string
password: string
affiliation?: string
position?: string
termsAccepted: boolean
orcidToken?: string
}
1 change: 1 addition & 0 deletions src/users/domain/models/AuthenticatedUser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,5 @@ export interface AuthenticatedUser {
affiliation?: string
position?: string
emailLastConfirmed?: string
authenticatedOrcid?: string
}
9 changes: 9 additions & 0 deletions src/users/domain/models/OrcidSignUpConnection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export interface OrcidSignUpConnection {
orcid: string
orcidToken: string
firstName?: string
lastName?: string
emailAddress?: string
affiliation?: string
position?: string
}
10 changes: 10 additions & 0 deletions src/users/domain/models/SignUpResult.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export type SignUpResult = SignUpCreatedResult | SignUpPendingResult

export interface SignUpCreatedResult {
status: 'created'
identifier: string
}

export interface SignUpPendingResult {
status: 'pending'
}
7 changes: 7 additions & 0 deletions src/users/domain/repositories/IUsersRepository.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
import { ApiTokenInfo } from '../models/ApiTokenInfo'
import { AuthenticatedUser } from '../models/AuthenticatedUser'
import { UserDTO } from '../dtos/UserDTO'
import { SignUpDTO } from '../dtos/SignUpDTO'
import { OrcidSignUpCallbackDTO } from '../dtos/OrcidSignUpCallbackDTO'
import { SignUpResult } from '../models/SignUpResult'
import { OrcidSignUpConnection } from '../models/OrcidSignUpConnection'

export interface IUsersRepository {
getCurrentAuthenticatedUser(): Promise<AuthenticatedUser>
recreateCurrentApiToken(): Promise<ApiTokenInfo>
getCurrentApiToken(): Promise<ApiTokenInfo>
deleteCurrentApiToken(): Promise<void>
registerUser(userDTO: UserDTO): Promise<void>
signUp(signUpDTO: SignUpDTO): Promise<SignUpResult>
getOrcidSignUpAuthorizationUrl(redirectUri: string): Promise<string>
connectOrcidForSignUp(callback: OrcidSignUpCallbackDTO): Promise<OrcidSignUpConnection>
}
24 changes: 24 additions & 0 deletions src/users/domain/useCases/ConnectOrcidForSignUp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { UseCase } from '../../../core/domain/useCases/UseCase'
import { OrcidSignUpCallbackDTO } from '../dtos/OrcidSignUpCallbackDTO'
import { OrcidSignUpConnection } from '../models/OrcidSignUpConnection'
import { IUsersRepository } from '../repositories/IUsersRepository'

export class ConnectOrcidForSignUp implements UseCase<OrcidSignUpConnection> {
private usersRepository: IUsersRepository

constructor(usersRepository: IUsersRepository) {
this.usersRepository = usersRepository
}

/**
* Exchanges the code and state that ORCID returns to the redirect URI for the user's authenticated ORCID iD.
* No authentication is used.
*
* @param {OrcidSignUpCallbackDTO} [callback] - The code and state from ORCID's redirect, and the same redirectUri used to get the authorization URL.
* @returns {Promise<OrcidSignUpConnection>} - The ORCID iD, any details the user shares on ORCID, and an orcidToken to include when signing up.
* @throws {WriteError} - If the state is invalid or expired, ORCID rejects the code, or the ORCID iD is already linked to another account (statusCode 409).
*/
async execute(callback: OrcidSignUpCallbackDTO): Promise<OrcidSignUpConnection> {
return await this.usersRepository.connectOrcidForSignUp(callback)
}
}
22 changes: 22 additions & 0 deletions src/users/domain/useCases/GetOrcidSignUpAuthorizationUrl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { UseCase } from '../../../core/domain/useCases/UseCase'
import { IUsersRepository } from '../repositories/IUsersRepository'

export class GetOrcidSignUpAuthorizationUrl implements UseCase<string> {
private usersRepository: IUsersRepository

constructor(usersRepository: IUsersRepository) {
this.usersRepository = usersRepository
}

/**
* Returns the ORCID authorization URL that sends a new user to ORCID to authenticate their ORCID iD while signing up.
* No authentication is used.
*
* @param {string} [redirectUri] - The URL ORCID redirects back to. Must be allowed by the installation's dataverse.api.signup.orcid-redirect-uris setting.
* @returns {Promise<string>} - The ORCID authorization URL.
* @throws {ReadError} - If ORCID is not configured, or the redirect URI is not allowed.
*/
async execute(redirectUri: string): Promise<string> {
return await this.usersRepository.getOrcidSignUpAuthorizationUrl(redirectUri)
}
}
Loading
Loading