Support signing up to an existing organization (#7145)

* wip

* wip. includes broken code

* Pass organization id when user is created

* Make backend endpoints properly error

The "set username" screen currently returns 500 server error
This commit is contained in:
somebody1234 2023-07-04 19:13:53 +10:00 committed by GitHub
parent 80f370c3a9
commit b243a5f529
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 122 additions and 24 deletions

View File

@ -61,6 +61,31 @@ Please verify your email first.`,
},
}
// ================
// === UserInfo ===
// ================
// The names come from a third-party API and cannot be changed.
/* eslint-disable @typescript-eslint/naming-convention */
/** Attributes returned from {@link amplify.Auth.currentUserInfo}. */
interface UserAttributes {
email: string
email_verified: boolean
sub: string
'custom:fromDesktop'?: string
'custom:organizationId'?: string
}
/* eslint-enable @typescript-eslint/naming-convention */
/** User information returned from {@link amplify.Auth.currentUserInfo}. */
interface UserInfo {
username: string
// The type comes from a third-party API and cannot be changed.
// eslint-disable-next-line no-restricted-syntax
id: undefined
attributes: UserAttributes
}
// ====================
// === AmplifyError ===
// ====================
@ -170,11 +195,20 @@ export class Cognito {
return userSession()
}
/** Returns the associated organization ID of the current user, which is passed during signup,
* or `null` if the user is not associated with an existing organization. */
async organizationId() {
// This `any` comes from a third-party API and cannot be avoided.
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const userInfo: UserInfo = await amplify.Auth.currentUserInfo()
return userInfo.attributes['custom:organizationId'] ?? null
}
/** Sign up with username and password.
*
* Does not rely on federated identity providers (e.g., Google or GitHub). */
signUp(username: string, password: string) {
return signUp(this.supportsDeepLinks, username, password)
signUp(username: string, password: string, organizationId: string | null) {
return signUp(this.supportsDeepLinks, username, password, organizationId)
}
/** Send the email address verification code.
@ -340,9 +374,14 @@ function intoCurrentSessionErrorKind(error: unknown): CurrentSessionErrorKind {
/** A wrapper around the Amplify "sign up" endpoint that converts known errors
* to {@link SignUpError}s. */
async function signUp(supportsDeepLinks: boolean, username: string, password: string) {
async function signUp(
supportsDeepLinks: boolean,
username: string,
password: string,
organizationId: string | null
) {
const result = await results.Result.wrapAsync(async () => {
const params = intoSignUpParams(supportsDeepLinks, username, password)
const params = intoSignUpParams(supportsDeepLinks, username, password, organizationId)
await amplify.Auth.signUp(params)
})
return result.mapErr(intoAmplifyErrorOrThrow).mapErr(intoSignUpErrorOrThrow)
@ -352,7 +391,8 @@ async function signUp(supportsDeepLinks: boolean, username: string, password: st
function intoSignUpParams(
supportsDeepLinks: boolean,
username: string,
password: string
password: string,
organizationId: string | null
): amplify.SignUpParams {
return {
username,
@ -369,7 +409,9 @@ function intoSignUpParams(
* It is necessary to disable the naming convention rule here, because the key is
* expected to appear exactly as-is in Cognito, so we must match it. */
// eslint-disable-next-line @typescript-eslint/naming-convention
...(supportsDeepLinks ? { 'custom:fromDesktop': JSON.stringify(true) } : {}),
'custom:fromDesktop': supportsDeepLinks ? JSON.stringify(true) : null,
// eslint-disable-next-line @typescript-eslint/naming-convention
'custom:organizationId': organizationId,
},
}
}

View File

@ -5,7 +5,7 @@ import * as router from 'react-router-dom'
import toast from 'react-hot-toast'
import * as app from '../../components/app'
import * as auth from '../providers/auth'
import * as authModule from '../providers/auth'
import * as hooks from '../../hooks'
import * as loggerProvider from '../../providers/logger'
@ -25,27 +25,28 @@ const REGISTRATION_QUERY_PARAMS = {
/** An empty component redirecting users based on the backend response to user registration. */
function ConfirmRegistration() {
const logger = loggerProvider.useLogger()
const { confirmSignUp } = auth.useAuth()
const { search } = router.useLocation()
const auth = authModule.useAuth()
const location = router.useLocation()
const navigate = hooks.useNavigate()
const { verificationCode, email } = parseUrlSearchParams(search)
const { verificationCode, email } = parseUrlSearchParams(location.search)
react.useEffect(() => {
if (!email || !verificationCode) {
navigate(app.LOGIN_PATH)
} else {
confirmSignUp(email, verificationCode)
.then(() => {
navigate(app.LOGIN_PATH + search.toString())
})
.catch(error => {
void (async () => {
try {
await auth.confirmSignUp(email, verificationCode)
navigate(app.LOGIN_PATH + location.search.toString())
} catch (error) {
logger.error('Error while confirming sign-up', error)
toast.error(
'Something went wrong! Please try again or contact the administrators.'
)
navigate(app.LOGIN_PATH)
})
}
})()
}
}, [])

View File

@ -9,31 +9,42 @@ import GoBackIcon from 'enso-assets/go_back.svg'
import LockIcon from 'enso-assets/lock.svg'
import * as app from '../../components/app'
import * as auth from '../providers/auth'
import * as authModule from '../providers/auth'
import * as svg from '../../components/svg'
import * as validation from '../../dashboard/validation'
import Input from './input'
import SvgIcon from './svgIcon'
// =================
// === Constants ===
// =================
const REGISTRATION_QUERY_PARAMS = {
organizationId: 'organization_id',
} as const
// ====================
// === Registration ===
// ====================
/** A form for users to register an account. */
function Registration() {
const { signUp } = auth.useAuth()
const auth = authModule.useAuth()
const location = router.useLocation()
const [email, setEmail] = react.useState('')
const [password, setPassword] = react.useState('')
const [confirmPassword, setConfirmPassword] = react.useState('')
const { organizationId } = parseUrlSearchParams(location.search)
const onSubmit = () => {
/** The password & confirm password fields must match. */
if (password !== confirmPassword) {
toast.error('Passwords do not match.')
return Promise.resolve()
} else {
return signUp(email, password)
return auth.signUp(email, password, organizationId)
}
}
@ -158,4 +169,11 @@ function Registration() {
)
}
/** Return an object containing the query parameters, with keys renamed to `camelCase`. */
function parseUrlSearchParams(search: string) {
const query = new URLSearchParams(search)
const organizationId = query.get(REGISTRATION_QUERY_PARAMS.organizationId)
return { organizationId }
}
export default Registration

View File

@ -109,7 +109,7 @@ export type UserSession = FullUserSession | OfflineUserSession | PartialUserSess
* See {@link Cognito} for details on each of the authentication functions. */
interface AuthContextType {
goOffline: () => Promise<boolean>
signUp: (email: string, password: string) => Promise<boolean>
signUp: (email: string, password: string, organizationId: string | null) => Promise<boolean>
confirmSignUp: (email: string, code: string) => Promise<boolean>
setUsername: (
backend: backendProvider.AnyBackendAPI,
@ -317,8 +317,8 @@ export function AuthProvider(props: AuthProviderProps) {
return Promise.resolve(true)
}
const signUp = async (username: string, password: string) => {
const result = await cognito.signUp(username, password)
const signUp = async (username: string, password: string, organizationId: string | null) => {
const result = await cognito.signUp(username, password, organizationId)
if (result.ok) {
toast.success(MESSAGES.signUpSuccess)
navigate(app.LOGIN_PATH)
@ -368,10 +368,17 @@ export function AuthProvider(props: AuthProviderProps) {
return false
} else {
try {
const organizationId = await authService.cognito.organizationId()
await toast.promise(
backend.createUser({
userName: username,
userEmail: newtype.asNewtype<backendModule.EmailAddress>(email),
organizationId:
organizationId != null
? newtype.asNewtype<backendModule.UserOrOrganizationId>(
organizationId
)
: null,
}),
{
success: MESSAGES.setUsernameSuccess,

View File

@ -36,6 +36,8 @@ const CONFIRM_REGISTRATION_PATHNAME = '//auth/confirmation'
/** Pathname of the {@link URL} for deep links to the login page, after a redirect from a reset
* password email. */
const LOGIN_PATHNAME = '//auth/login'
/** Pathname of the {@link URL} for deep links to the registration page. */
const REGISTRATION_PATHNAME = '//auth/registration'
/** URI used as the OAuth redirect when deep links are supported. */
const DEEP_LINK_REDIRECT = newtype.asNewtype<auth.OAuthRedirect>(
@ -229,6 +231,9 @@ function setDeepLinkHandler(logger: loggerProvider.Logger, navigate: (url: strin
case LOGIN_PATHNAME:
navigate(app.LOGIN_PATH)
break
case REGISTRATION_PATHNAME:
navigate(app.REGISTRATION_PATH + parsedUrl.search)
break
/** If the user is being redirected from a password reset email, then we need to navigate to
* the password reset page, with the verification code and email passed in the URL s-o they can
* be filled in automatically. */

View File

@ -291,6 +291,13 @@ export interface Directory extends Asset<AssetType.directory> {}
export interface CreateUserRequestBody {
userName: string
userEmail: EmailAddress
organizationId: UserOrOrganizationId | null
}
/** HTTP request body for the "invite user" endpoint. */
export interface InviteUserRequestBody {
organizationId: UserOrOrganizationId
userEmail: EmailAddress
}
/** HTTP request body for the "create directory" endpoint. */
@ -377,6 +384,8 @@ export interface Backend {
/** Set the username of the current user. */
createUser: (body: CreateUserRequestBody) => Promise<UserOrOrganization>
/** Return user details for the current user. */
inviteUser: (body: InviteUserRequestBody) => Promise<void>
/** Return user details for the current user. */
usersMe: () => Promise<UserOrOrganization | null>
/** Return a list of assets in a directory. */
listDirectory: (query: ListDirectoryRequestParams) => Promise<Asset[]>

View File

@ -40,6 +40,8 @@ function responseIsSuccessful(response: Response) {
/** Relative HTTP path to the "set username" endpoint of the Cloud backend API. */
const CREATE_USER_PATH = 'users'
/** Relative HTTP path to the "set username" endpoint of the Cloud backend API. */
const INVITE_USER_PATH = 'users/invite'
/** Relative HTTP path to the "get user" endpoint of the Cloud backend API. */
const USERS_ME_PATH = 'users/me'
/** Relative HTTP path to the "list directory" endpoint of the Cloud backend API. */
@ -170,11 +172,25 @@ export class RemoteBackend implements backend.Backend {
throw new Error(message)
}
/** Set the username of the current user. */
/** Set the username and parent organization of the current user. */
async createUser(body: backend.CreateUserRequestBody): Promise<backend.UserOrOrganization> {
const response = await this.post<backend.UserOrOrganization>(CREATE_USER_PATH, body)
if (!responseIsSuccessful(response)) {
return this.throw('Unable to create user.')
} else {
return await response.json()
}
}
/** Set the username of the current user. */
async inviteUser(body: backend.InviteUserRequestBody): Promise<void> {
const response = await this.post(INVITE_USER_PATH, body)
if (!responseIsSuccessful(response)) {
return this.throw(`Unable to invite user with email '${body.userEmail}'.`)
} else {
return
}
}
/** Return organization info for the current user.
*