mirror of
https://github.com/enso-org/enso.git
synced 2024-11-23 16:18:23 +03:00
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:
parent
80f370c3a9
commit
b243a5f529
@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
})
|
||||
}
|
||||
})()
|
||||
}
|
||||
}, [])
|
||||
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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. */
|
||||
|
@ -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[]>
|
||||
|
@ -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,10 +172,24 @@ 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)
|
||||
return await response.json()
|
||||
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.
|
||||
|
Loading…
Reference in New Issue
Block a user