Skip to content

Commit 292ac6d

Browse files
committed
feat: add sign-up and ORCID-at-sign-up use cases
Adds SignUp, GetOrcidSignUpAuthorizationUrl and ConnectOrcidForSignUp for POST /users/signup, GET /users/signup/orcid/authorize and POST /users/signup/orcid, which are called without authentication. AuthenticatedUser gains authenticatedOrcid. ReadError and WriteError now carry the HTTP statusCode and the server's fieldErrors, so clients can show validation errors next to form fields.
1 parent 4281c37 commit 292ac6d

27 files changed

Lines changed: 830 additions & 18 deletions

‎CHANGELOG.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,15 @@ This changelog follows the principles of [Keep a Changelog](https://keepachangel
88

99
### Added
1010

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

18+
- Core: `ApiRepository.doPost` accepts an optional `authRequired` argument (default `true`) to send a request without the configured credentials.
19+
1320
### Fixed
1421

1522
### Removed

‎docs/useCases.md‎

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,10 +109,13 @@ The different use cases currently available in the package are classified below,
109109
- [Users read use cases](#users-read-use-cases)
110110
- [Get Current Authenticated User](#get-current-authenticated-user)
111111
- [Get Current API Token](#get-current-api-token)
112+
- [Get ORCID Sign-Up Authorization URL](#get-orcid-sign-up-authorization-url)
112113
- [Users write use cases](#users-write-use-cases)
113114
- [Delete Current API Token](#delete-current-api-token)
114115
- [Recreate Current API Token](#recreate-current-api-token)
115116
- [Register User](#register-user)
117+
- [Sign Up](#sign-up)
118+
- [Connect ORCID For Sign-Up](#connect-orcid-for-sign-up)
116119
- [Roles](#Roles)
117120
- [Roles read use cases](#roles-read-use-cases)
118121
- [Get User Selectable Roles](#get-user-selectable-roles)
@@ -2747,6 +2750,8 @@ There is a third optional parameter called `datasetType` which will include addi
27472750

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

2753+
The `authenticatedOrcid` property holds the URL of the ORCID iD the user has authenticated and linked to their account, if any.
2754+
27502755
##### Example call:
27512756

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

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

2791+
#### Get ORCID Sign-Up Authorization URL
2792+
2793+
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).
2794+
2795+
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.
2796+
2797+
##### Example call:
2798+
2799+
```typescript
2800+
import { getOrcidSignUpAuthorizationUrl } from '@iqss/dataverse-client-javascript'
2801+
2802+
/* ... */
2803+
2804+
const redirectUri = 'https://dataverse.example.edu/spa/sign-up'
2805+
2806+
getOrcidSignUpAuthorizationUrl.execute(redirectUri).then((authorizationUrl: string) => {
2807+
window.location.assign(authorizationUrl)
2808+
})
2809+
2810+
/* ... */
2811+
```
2812+
2813+
_See [use case](../src/users/domain/useCases/GetOrcidSignUpAuthorizationUrl.ts) implementation_.
2814+
2815+
The use case throws a `ReadError` with `statusCode` 404 when no ORCID provider is configured, and 400 when the redirect URI is not allowed.
2816+
27862817
### Users write use cases
27872818

27882819
### Delete Current API Token
@@ -2851,6 +2882,81 @@ registerUser.execute(userDTO)
28512882

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

2885+
### Sign Up
2886+
2887+
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`).
2888+
2889+
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).
2890+
2891+
##### Example call:
2892+
2893+
```typescript
2894+
import { signUp } from '@iqss/dataverse-client-javascript'
2895+
2896+
/* ... */
2897+
2898+
const signUpDTO: SignUpDTO = {
2899+
username: 'alovelace',
2900+
firstName: 'Ada',
2901+
lastName: 'Lovelace',
2902+
emailAddress: 'ada@example.edu',
2903+
password: 'Str0ngPassw0rd',
2904+
affiliation: 'Harvard University',
2905+
termsAccepted: true
2906+
}
2907+
2908+
signUp
2909+
.execute(signUpDTO)
2910+
.then((result: SignUpResult) => {
2911+
if (result.status === 'created') {
2912+
/* the account exists; result.identifier is e.g. '@alovelace' */
2913+
} else {
2914+
/* status 'pending': the installation sent a verification email first */
2915+
}
2916+
})
2917+
.catch((error: WriteError) => {
2918+
/* error.fieldErrors maps invalid fields to messages, e.g. { username: 'This username is already taken.' } */
2919+
})
2920+
2921+
/* ... */
2922+
```
2923+
2924+
_See [use case](../src/users/domain/useCases/SignUp.ts) implementation_.
2925+
2926+
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.
2927+
2928+
### Connect ORCID For Sign-Up
2929+
2930+
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.
2931+
2932+
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.
2933+
2934+
##### Example call:
2935+
2936+
```typescript
2937+
import { connectOrcidForSignUp } from '@iqss/dataverse-client-javascript'
2938+
2939+
/* ... */
2940+
2941+
const params = new URLSearchParams(window.location.search)
2942+
2943+
connectOrcidForSignUp
2944+
.execute({
2945+
code: params.get('code') as string,
2946+
state: params.get('state') as string,
2947+
redirectUri: 'https://dataverse.example.edu/spa/sign-up'
2948+
})
2949+
.then((connection: OrcidSignUpConnection) => {
2950+
/* connection.orcid, connection.firstName, ..., connection.orcidToken */
2951+
})
2952+
2953+
/* ... */
2954+
```
2955+
2956+
_See [use case](../src/users/domain/useCases/ConnectOrcidForSignUp.ts) implementation_.
2957+
2958+
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.
2959+
28542960
## Roles
28552961

28562962
### Get User Selectable Roles
Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import { RepositoryError } from './RepositoryError'
1+
import { RepositoryError, RepositoryErrorDetails } from './RepositoryError'
22

33
export class ReadError extends RepositoryError {
4-
constructor(reason?: string) {
5-
super('There was an error when reading the resource.', reason)
4+
constructor(reason?: string, details?: RepositoryErrorDetails) {
5+
super('There was an error when reading the resource.', reason, details)
66
}
77
}
Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,18 @@
1+
export interface RepositoryErrorDetails {
2+
statusCode?: number
3+
fieldErrors?: Record<string, string>
4+
}
5+
16
export abstract class RepositoryError extends Error {
2-
constructor(message: string, reason?: string) {
7+
readonly statusCode?: number
8+
readonly fieldErrors?: Record<string, string>
9+
10+
constructor(message: string, reason?: string, details?: RepositoryErrorDetails) {
311
if (reason) {
412
message += ` Reason was: ${reason}`
513
}
614
super(message)
15+
this.statusCode = details?.statusCode
16+
this.fieldErrors = details?.fieldErrors
717
}
818
}
Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import { RepositoryError } from './RepositoryError'
1+
import { RepositoryError, RepositoryErrorDetails } from './RepositoryError'
22

33
export class WriteError extends RepositoryError {
4-
constructor(reason?: string) {
5-
super('There was an error when writing the resource.', reason)
4+
constructor(reason?: string, details?: RepositoryErrorDetails) {
5+
super('There was an error when writing the resource.', reason, details)
66
}
77
}

‎src/core/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export { ReadError } from './domain/repositories/ReadError'
22
export { WriteError } from './domain/repositories/WriteError'
3+
export { RepositoryErrorDetails } from './domain/repositories/RepositoryError'
34
export { ApiConfig } from './infra/repositories/ApiConfig'
45
export { DvObjectOwnerNode, DvObjectType } from './domain/models/DvObjectOwnerNode'
56
export { PublicationStatus } from './domain/models/PublicationStatus'

‎src/core/infra/repositories/ApiRepository.ts‎

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import axios, { AxiosResponse } from 'axios'
22
import { ReadError } from '../../domain/repositories/ReadError'
33
import { WriteError } from '../../domain/repositories/WriteError'
4+
import { RepositoryErrorDetails } from '../../domain/repositories/RepositoryError'
45
import { buildRequestConfig, buildRequestUrl } from './apiConfigBuilders'
56
import { ApiConstants } from './ApiConstants'
67

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

2122
public async doPost(
2223
apiEndpoint: string,
2324
data: string | object | boolean,
2425
queryParams: object = {},
25-
contentType: string = ApiConstants.CONTENT_TYPE_APPLICATION_JSON
26+
contentType: string = ApiConstants.CONTENT_TYPE_APPLICATION_JSON,
27+
authRequired = true
2628
): Promise<AxiosResponse> {
27-
return await this.doRequest('post', apiEndpoint, data, queryParams, contentType)
29+
return await this.doRequest('post', apiEndpoint, data, queryParams, contentType, authRequired)
2830
}
2931

3032
public async doPut(
@@ -41,7 +43,7 @@ export abstract class ApiRepository {
4143
.delete(buildRequestUrl(apiEndpoint), buildRequestConfig(true, queryParams))
4244
.then((response) => response)
4345
.catch((error) => {
44-
throw new WriteError(this.buildErrorMessage(error))
46+
throw new WriteError(this.buildErrorMessage(error), this.buildErrorDetails(error))
4547
})
4648
}
4749

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

64+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
65+
private buildErrorDetails(error: any): RepositoryErrorDetails {
66+
const fieldErrors = error?.response?.data?.fieldErrors
67+
return {
68+
statusCode: error?.response?.status,
69+
fieldErrors: fieldErrors && typeof fieldErrors === 'object' ? fieldErrors : undefined
70+
}
71+
}
72+
6273
// eslint-disable-next-line @typescript-eslint/no-explicit-any
6374
private buildErrorMessage(error: any): string {
6475
const status =
@@ -72,7 +83,8 @@ export abstract class ApiRepository {
7283
apiEndpoint: string,
7384
data: string | object | boolean,
7485
queryParams: object = {},
75-
contentType: string = ApiConstants.CONTENT_TYPE_APPLICATION_JSON
86+
contentType: string = ApiConstants.CONTENT_TYPE_APPLICATION_JSON,
87+
authRequired = true
7688
): Promise<AxiosResponse> {
7789
let requestData = data
7890

@@ -83,13 +95,13 @@ export abstract class ApiRepository {
8395
}
8496

8597
const requestUrl = buildRequestUrl(apiEndpoint)
86-
const requestConfig = buildRequestConfig(true, queryParams, contentType)
98+
const requestConfig = buildRequestConfig(authRequired, queryParams, contentType)
8799

88100
try {
89101
const response = await axios[method](requestUrl, requestData, requestConfig)
90102
return response
91103
} catch (error) {
92-
throw new WriteError(this.buildErrorMessage(error))
104+
throw new WriteError(this.buildErrorMessage(error), this.buildErrorDetails(error))
93105
}
94106
}
95107
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export interface OrcidSignUpCallbackDTO {
2+
code: string
3+
state: string
4+
redirectUri: string
5+
}

‎src/users/domain/dtos/SignUpDTO.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export interface SignUpDTO {
2+
username: string
3+
firstName: string
4+
lastName: string
5+
emailAddress: string
6+
password: string
7+
affiliation?: string
8+
position?: string
9+
termsAccepted: boolean
10+
orcidToken?: string
11+
}

‎src/users/domain/models/AuthenticatedUser.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,5 @@ export interface AuthenticatedUser {
1616
affiliation?: string
1717
position?: string
1818
emailLastConfirmed?: string
19+
authenticatedOrcid?: string
1920
}

0 commit comments

Comments
 (0)