UI Onboarding UI/UX auth refactoring

This commit is contained in:
Dean 2023-01-27 13:44:57 +00:00
parent ac520e3a1e
commit d37c0e4b5d
13 changed files with 622 additions and 285 deletions

View File

@ -4,37 +4,45 @@
Heading, Heading,
notifications, notifications,
Layout, Layout,
Input,
Body, Body,
ActionButton,
Modal, Modal,
} from "@budibase/bbui" } from "@budibase/bbui"
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import { API } from "api" import { API } from "api"
import { admin, auth } from "stores/portal" import { admin, auth } from "stores/portal"
import PasswordRepeatInput from "components/common/users/PasswordRepeatInput.svelte"
import ImportAppsModal from "./_components/ImportAppsModal.svelte" import ImportAppsModal from "./_components/ImportAppsModal.svelte"
import Logo from "assets/bb-emblem.svg" import Logo from "assets/bb-emblem.svg"
import { onMount } from "svelte" import { onMount } from "svelte"
import { FancyForm, FancyInput, ActionButton } from "@budibase/bbui"
import { TestimonialPage } from "@budibase/frontend-core/src/components"
import { handleError, passwordsMatch } from "../auth/_components/utils"
let adminUser = {}
let error
let modal let modal
let form
let errors = {}
let formData = {}
let submitted = false
$: tenantId = $auth.tenantId $: tenantId = $auth.tenantId
$: multiTenancyEnabled = $admin.multiTenancy
$: cloud = $admin.cloud $: cloud = $admin.cloud
$: imported = $admin.importComplete $: imported = $admin.importComplete
$: multiTenancyEnabled = $admin.multiTenancy
async function save() { async function save() {
form.validate()
if (Object.keys(errors).length > 0) {
return
}
submitted = true
try { try {
adminUser.tenantId = tenantId const adminUser = { ...formData, tenantId }
// Save the admin user // Save the admin user
await API.createAdminUser(adminUser) await API.createAdminUser(adminUser)
notifications.success("Admin user created") notifications.success("Admin user created")
await admin.init() await admin.init()
$goto("../portal") $goto("../portal")
} catch (error) { } catch (error) {
submitted = false
notifications.error("Failed to create admin user") notifications.error("Failed to create admin user")
} }
} }
@ -53,35 +61,109 @@
<Modal bind:this={modal} padding={false} width="600px"> <Modal bind:this={modal} padding={false} width="600px">
<ImportAppsModal /> <ImportAppsModal />
</Modal> </Modal>
<section>
<div class="container"> <TestimonialPage>
<Layout> <Layout gap="M" noPadding>
<Layout justifyItems="center" noPadding>
<img alt="logo" src={Logo} /> <img alt="logo" src={Logo} />
<Layout gap="XS" justifyItems="center" noPadding> <Heading size="M">Create an admin user</Heading>
<Heading size="M">Create an admin user</Heading> <Body>The admin user has access to everything in Budibase.</Body>
<Body size="M" textAlign="center"> </Layout>
The admin user has access to everything in Budibase. <Layout gap="S" noPadding>
</Body> <FancyForm bind:this={form}>
</Layout> <FancyInput
<Layout gap="XS" noPadding> label="Email"
<Input label="Email" bind:value={adminUser.email} /> value={formData.email}
<PasswordRepeatInput bind:password={adminUser.password} bind:error /> on:change={e => {
</Layout> formData = {
<Layout gap="XS" noPadding> ...formData,
<Button cta disabled={error} on:click={save}> email: e.detail,
Create super admin user }
</Button> }}
{#if multiTenancyEnabled} validate={() => {
<ActionButton handleError(() => {
quiet return {
on:click={() => { email: !formData.email
admin.unload() ? "Please enter a valid email"
$goto("../auth/org") : undefined,
}} }
> }, errors)
Change organisation }}
</ActionButton> disabled={submitted}
{:else if !cloud && !imported} error={errors.email}
/>
<FancyInput
label="Password"
value={formData.password}
type="password"
on:change={e => {
formData = {
...formData,
password: e.detail,
}
}}
validate={() => {
handleError(() => {
let err = {}
err["password"] = !formData.password
? "Please enter a password"
: undefined
err["confirmationPassword"] =
!passwordsMatch(
formData.password,
formData.confirmationPassword
) && formData.confirmationPassword
? "Passwords must match"
: undefined
return err
}, errors)
}}
error={errors.password}
disabled={submitted}
/>
<FancyInput
label="Repeat Password"
value={formData.confirmationPassword}
type="password"
on:change={e => {
formData = {
...formData,
confirmationPassword: e.detail,
}
}}
validate={() => {
handleError(() => {
return {
confirmationPassword:
!passwordsMatch(
formData.password,
formData.confirmationPassword
) && formData.password
? "Passwords must match"
: undefined,
}
}, errors)
}}
error={errors.confirmationPassword}
disabled={submitted}
/>
</FancyForm>
</Layout>
<Layout gap="XS" noPadding justifyItems="center">
<Button
cta
disabled={Object.keys(errors).length > 0 || submitted}
on:click={save}
>
Create super admin user
</Button>
</Layout>
<Layout gap="XS" noPadding justifyItems="center">
<div class="user-actions">
{#if !cloud && !imported && !multiTenancyEnabled}
<ActionButton <ActionButton
quiet quiet
on:click={() => { on:click={() => {
@ -91,28 +173,13 @@
Import from cloud Import from cloud
</ActionButton> </ActionButton>
{/if} {/if}
</Layout> </div>
</Layout> </Layout>
</div> </Layout>
</section> </TestimonialPage>
<style> <style>
section {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.container {
margin: 0 auto;
width: 260px;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
}
img { img {
width: 48px; width: 48px;
margin: 0 auto;
} }
</style> </style>

View File

@ -1,5 +1,5 @@
<script> <script>
import { ActionButton } from "@budibase/bbui" import { FancyButton } from "@budibase/bbui"
import GoogleLogo from "assets/google-logo.png" import GoogleLogo from "assets/google-logo.png"
import { auth, organisation } from "stores/portal" import { auth, organisation } from "stores/portal"
@ -10,31 +10,11 @@
</script> </script>
{#if show} {#if show}
<ActionButton <FancyButton
icon={GoogleLogo}
on:click={() => on:click={() =>
window.open(`/api/global/auth/${tenantId}/google`, "_blank")} window.open(`/api/global/auth/${tenantId}/google`, "_blank")}
> >
<div class="inner"> Log in with Google
<img src={GoogleLogo} alt="google icon" /> </FancyButton>
<p>Sign in with Google</p>
</div>
</ActionButton>
{/if} {/if}
<style>
.inner {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
padding-top: var(--spacing-xs);
padding-bottom: var(--spacing-xs);
}
.inner img {
width: 18px;
margin: 3px 10px 3px 3px;
}
.inner p {
margin: 0;
}
</style>

View File

@ -1,5 +1,5 @@
<script> <script>
import { ActionButton, notifications } from "@budibase/bbui" import { notifications, FancyButton } from "@budibase/bbui"
import OidcLogo from "assets/oidc-logo.png" import OidcLogo from "assets/oidc-logo.png"
import Auth0Logo from "assets/auth0-logo.png" import Auth0Logo from "assets/auth0-logo.png"
import MicrosoftLogo from "assets/microsoft-logo.png" import MicrosoftLogo from "assets/microsoft-logo.png"
@ -33,34 +33,14 @@
</script> </script>
{#if show} {#if show}
<ActionButton <FancyButton
icon={src}
on:click={() => on:click={() =>
window.open( window.open(
`/api/global/auth/${$auth.tenantId}/oidc/configs/${$oidc.uuid}`, `/api/global/auth/${$auth.tenantId}/oidc/configs/${$oidc.uuid}`,
"_blank" "_blank"
)} )}
> >
<div class="inner"> {`Log in with ${$oidc.name || "OIDC"}`}
<img {src} alt="oidc icon" /> </FancyButton>
<p>{`Sign in with ${$oidc.name || "OIDC"}`}</p>
</div>
</ActionButton>
{/if} {/if}
<style>
.inner {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
padding-top: var(--spacing-xs);
padding-bottom: var(--spacing-xs);
}
.inner img {
width: 18px;
margin: 3px 10px 3px 3px;
}
.inner p {
margin: 0;
}
</style>

View File

@ -0,0 +1,18 @@
exports.handleError = (validate, errors) => {
const err = validate()
let update = { ...errors, ...err }
errors = Object.keys(update).reduce((acc, key) => {
if (update[key]) {
acc[key] = update[key]
}
return acc
}, {})
}
exports.passwordsMatch = (password, confirmation) => {
let confirm = confirmation?.trim()
let pwd = password?.trim()
return (
typeof confirm === "string" && typeof pwd === "string" && confirm == pwd
)
}

View File

@ -1,25 +1,35 @@
<script> <script>
import { import {
notifications, notifications,
Input,
Button, Button,
Layout, Layout,
Body, Body,
Heading, Heading,
ActionButton, Icon,
} from "@budibase/bbui" } from "@budibase/bbui"
import { organisation, auth } from "stores/portal" import { organisation, auth } from "stores/portal"
import Logo from "assets/bb-emblem.svg" import Logo from "assets/bb-emblem.svg"
import { onMount } from "svelte" import { onMount } from "svelte"
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import { TestimonialPage } from "@budibase/frontend-core/src/components"
import { FancyForm, FancyInput } from "@budibase/bbui"
let email = "" let email = ""
let form
let error
let submitted = false
async function forgot() { async function forgot() {
form.validate()
if (error) {
return
}
submitted = true
try { try {
await auth.forgotPassword(email) await auth.forgotPassword(email)
notifications.success("Email sent - please check your inbox") notifications.success("Email sent - please check your inbox")
} catch (err) { } catch (err) {
submitted = false
notifications.error("Unable to send reset password link") notifications.error("Unable to send reset password link")
} }
} }
@ -33,45 +43,64 @@
}) })
</script> </script>
<div class="login"> <TestimonialPage>
<div class="main"> <Layout gap="M" noPadding>
<Layout> <img alt="logo" src={$organisation.logoUrl || Logo} />
<Layout noPadding justifyItems="center"> <span class="heading-wrap">
<img alt="logo" src={$organisation.logoUrl || Logo} /> <Heading size="M">
</Layout> <div class="heading-content">
<Layout gap="XS" noPadding> <span class="back-chev" on:click={() => $goto("../")}>
<Heading textAlign="center">Forgotten your password?</Heading> <Icon name="ChevronLeft" size="XL" />
<Body size="S" textAlign="center"> </span>
No problem! Just enter your account's email address and we'll send you Forgotten your password?
a link to reset it. </div>
</Body> </Heading>
<Input label="Email" bind:value={email} /> </span>
</Layout> <Layout gap="XS" noPadding>
<Layout gap="XS" nopadding> <Body size="M">
<Button cta on:click={forgot} disabled={!email}> No problem! Just enter your account's email address and we'll send you a
Reset your password link to reset it.
</Button> </Body>
<ActionButton quiet on:click={() => $goto("../")}>Back</ActionButton>
</Layout>
</Layout> </Layout>
</div>
</div> <Layout gap="S" noPadding>
<FancyForm bind:this={form}>
<FancyInput
label="Email"
value={email}
on:change={e => {
email = e.detail
}}
validate={() => {
if (!email) {
return "Please enter your email"
}
return null
}}
{error}
disabled={submitted}
/>
</FancyForm>
</Layout>
<div>
<Button disabled={!email || error || submitted} cta on:click={forgot}>
Reset password
</Button>
</div>
</Layout>
</TestimonialPage>
<style> <style>
.login {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.main {
width: 300px;
}
img { img {
width: 48px; width: 48px;
} }
.back-chev {
display: inline-block;
cursor: pointer;
margin-left: -5px;
}
.heading-content {
display: flex;
align-items: center;
}
</style> </style>

View File

@ -5,7 +5,6 @@
Button, Button,
Divider, Divider,
Heading, Heading,
Input,
Layout, Layout,
notifications, notifications,
Link, Link,
@ -14,22 +13,30 @@
import { auth, organisation, oidc, admin } from "stores/portal" import { auth, organisation, oidc, admin } from "stores/portal"
import GoogleButton from "./_components/GoogleButton.svelte" import GoogleButton from "./_components/GoogleButton.svelte"
import OIDCButton from "./_components/OIDCButton.svelte" import OIDCButton from "./_components/OIDCButton.svelte"
import { handleError } from "./_components/utils"
import Logo from "assets/bb-emblem.svg" import Logo from "assets/bb-emblem.svg"
import { TestimonialPage } from "@budibase/frontend-core/src/components"
import { FancyForm, FancyInput } from "@budibase/bbui"
import { onMount } from "svelte" import { onMount } from "svelte"
let username = ""
let password = ""
let loaded = false let loaded = false
let form
let errors = {}
let formData = {}
$: company = $organisation.company || "Budibase" $: company = $organisation.company || "Budibase"
$: multiTenancyEnabled = $admin.multiTenancy
$: cloud = $admin.cloud $: cloud = $admin.cloud
async function login() { async function login() {
form.validate()
if (Object.keys(errors).length > 0) {
console.log("errors")
return
}
try { try {
await auth.login({ await auth.login({
username: username.trim(), username: formData?.username.trim(),
password, password: formData?.password,
}) })
if ($auth?.user?.forceResetPassword) { if ($auth?.user?.forceResetPassword) {
$goto("./reset") $goto("./reset")
@ -57,75 +64,98 @@
</script> </script>
<svelte:window on:keydown={handleKeydown} /> <svelte:window on:keydown={handleKeydown} />
<div class="login">
<div class="main"> <TestimonialPage>
<Layout> <Layout gap="M" noPadding>
<Layout noPadding justifyItems="center"> <Layout justifyItems="center" noPadding>
<img alt="logo" src={$organisation.logoUrl || Logo} />
<Heading textAlign="center">Sign in to {company}</Heading>
</Layout>
{#if loaded} {#if loaded}
<GoogleButton /> <img alt="logo" src={$organisation.logoUrl || Logo} />
<OIDCButton oidcIcon={$oidc.logo} oidcName={$oidc.name} />
{/if}
<Divider noGrid />
<Layout gap="XS" noPadding>
<Body size="S" textAlign="center">Sign in with email</Body>
<Input label="Email" bind:value={username} />
<Input
label="Password"
type="password"
on:change
bind:value={password}
/>
</Layout>
<Layout gap="XS" noPadding>
<Button cta disabled={!username && !password} on:click={login}
>Sign in to {company}</Button
>
<ActionButton quiet on:click={() => $goto("./forgot")}>
Forgot password?
</ActionButton>
{#if multiTenancyEnabled && !cloud}
<ActionButton
quiet
on:click={() => {
admin.unload()
$goto("./org")
}}
>
Change organisation
</ActionButton>
{/if}
</Layout>
{#if cloud}
<Body size="xs" textAlign="center">
By using Budibase Cloud
<br />
you are agreeing to our
<Link href="https://budibase.com/eula" target="_blank"
>License Agreement</Link
>
</Body>
{/if} {/if}
<Heading size="M">Log in to Budibase</Heading>
</Layout> </Layout>
</div> <Layout gap="S" noPadding>
</div> {#if loaded && ($organisation.google || $organisation.oidc)}
<FancyForm>
<OIDCButton oidcIcon={$oidc.logo} oidcName={$oidc.name} />
<GoogleButton />
</FancyForm>
<Divider />
{/if}
<FancyForm bind:this={form}>
<FancyInput
label="Your work email"
value={formData.username}
on:change={e => {
formData = {
...formData,
username: e.detail,
}
}}
validate={() => {
handleError(() => {
return {
username: !formData.username
? "Please enter a valid email"
: undefined,
}
}, errors)
}}
error={errors.username}
/>
<FancyInput
label="Password"
value={formData.password}
type="password"
on:change={e => {
formData = {
...formData,
password: e.detail,
}
}}
validate={() => {
handleError(() => {
return {
password: !formData.password
? "Please enter your password"
: undefined,
}
}, errors)
}}
error={errors.password}
/>
</FancyForm>
</Layout>
<Layout gap="XS" noPadding justifyItems="center">
<Button cta disabled={Object.keys(errors).length > 0} on:click={login}>
Log in to {company}
</Button>
</Layout>
<Layout gap="XS" noPadding justifyItems="center">
<div class="user-actions">
<ActionButton quiet on:click={() => $goto("./forgot")}>
Forgot password
</ActionButton>
</div>
</Layout>
{#if cloud}
<Body size="xs" textAlign="center">
By using Budibase Cloud
<br />
you are agreeing to our
<Link href="https://budibase.com/eula" target="_blank" secondary={true}>
License Agreement
</Link>
</Body>
{/if}
</Layout>
</TestimonialPage>
<style> <style>
.login { .user-actions {
width: 100%;
height: 100%;
display: flex; display: flex;
flex-direction: column;
justify-content: center;
align-items: center; align-items: center;
} }
.main {
width: 300px;
}
img { img {
width: 48px; width: 48px;
} }

View File

@ -1,31 +1,44 @@
<script> <script>
import { Body, Button, Heading, Layout, notifications } from "@budibase/bbui" import { Body, Button, Heading, Layout, notifications } from "@budibase/bbui"
import { goto, params } from "@roxi/routify" import { goto, params } from "@roxi/routify"
import PasswordRepeatInput from "components/common/users/PasswordRepeatInput.svelte"
import { auth, organisation } from "stores/portal" import { auth, organisation } from "stores/portal"
import Logo from "assets/bb-emblem.svg" import Logo from "assets/bb-emblem.svg"
import { TestimonialPage } from "@budibase/frontend-core/src/components"
import { FancyForm, FancyInput } from "@budibase/bbui"
import { onMount } from "svelte" import { onMount } from "svelte"
import { handleError, passwordsMatch } from "./_components/utils"
const resetCode = $params["?code"] const resetCode = $params["?code"]
let password, error let form
let formData = {}
let errors = {}
$: console.log(errors)
$: submitted = false
$: forceResetPassword = $auth?.user?.forceResetPassword $: forceResetPassword = $auth?.user?.forceResetPassword
async function reset() { async function reset() {
form.validate()
if (Object.keys(errors).length > 0) {
return
}
submitted = true
try { try {
if (forceResetPassword) { if (forceResetPassword) {
await auth.updateSelf({ await auth.updateSelf({
password, password: formData.password,
forceResetPassword: false, forceResetPassword: false,
}) })
$goto("../portal/") $goto("../portal/")
} else { } else {
await auth.resetPassword(password, resetCode) await auth.resetPassword(formData.password, resetCode)
notifications.success("Password reset successfully") notifications.success("Password reset successfully")
// send them to login if reset successful // send them to login if reset successful
$goto("./login") $goto("./login")
} }
} catch (err) { } catch (err) {
submitted = false
notifications.error("Unable to reset password") notifications.error("Unable to reset password")
} }
} }
@ -40,44 +53,88 @@
}) })
</script> </script>
<div class="login"> <TestimonialPage>
<div class="main"> <Layout gap="M" noPadding>
<Layout> <img alt="logo" src={$organisation.logoUrl || Logo} />
<Layout noPadding justifyItems="center"> <Layout gap="XS" noPadding>
<img src={$organisation.logoUrl || Logo} alt="Organisation logo" /> <Heading size="M">Reset your password</Heading>
</Layout> <Body size="M">Please enter the new password you'd like to use.</Body>
<Layout gap="XS" noPadding>
<Heading textAlign="center">Reset your password</Heading>
<Body size="S" textAlign="center">
Please enter the new password you'd like to use.
</Body>
<PasswordRepeatInput bind:password bind:error />
</Layout>
<Button
cta
on:click={reset}
disabled={error || (forceResetPassword ? false : !resetCode)}
>
Reset your password
</Button>
</Layout> </Layout>
</div>
</div> <Layout gap="S" noPadding>
<FancyForm bind:this={form}>
<FancyInput
label="Password"
value={formData.password}
type="password"
on:change={e => {
formData = {
...formData,
password: e.detail,
}
}}
validate={() => {
handleError(() => {
let err = {}
err["password"] = !formData.password
? "Please enter a password"
: undefined
err["confirmationPassword"] =
!passwordsMatch(
formData.password,
formData.confirmationPassword
) && formData.confirmationPassword
? "Passwords must match"
: undefined
return err
}, errors)
}}
error={errors.password}
disabled={submitted}
/>
<FancyInput
label="Repeat Password"
value={formData.confirmationPassword}
type="password"
on:change={e => {
formData = {
...formData,
confirmationPassword: e.detail,
}
}}
validate={() => {
handleError(() => {
return {
confirmationPassword:
!passwordsMatch(
formData.password,
formData.confirmationPassword
) && formData.password
? "Passwords must match"
: undefined,
}
}, errors)
}}
error={errors.confirmationPassword}
disabled={submitted}
/>
</FancyForm>
</Layout>
<div>
<Button
disabled={Object.keys(errors).length > 0 ||
(forceResetPassword ? false : !resetCode)}
cta
on:click={reset}>Reset your password</Button
>
</div>
</Layout>
</TestimonialPage>
<style> <style>
.login {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.main {
width: 260px;
}
img { img {
width: 48px; width: 48px;
} }

View File

@ -1,70 +1,194 @@
<script> <script>
import { Layout, Heading, Body, Button, notifications } from "@budibase/bbui" import { Layout, Heading, Body, Button, notifications } from "@budibase/bbui"
import { goto, params } from "@roxi/routify" import { goto, params } from "@roxi/routify"
import { users, organisation } from "stores/portal" import { users, organisation, auth } from "stores/portal"
import PasswordRepeatInput from "components/common/users/PasswordRepeatInput.svelte"
import Logo from "assets/bb-emblem.svg" import Logo from "assets/bb-emblem.svg"
import { TestimonialPage } from "@budibase/frontend-core/src/components"
import { FancyForm, FancyInput } from "@budibase/bbui"
import { onMount } from "svelte" import { onMount } from "svelte"
import { handleError, passwordsMatch } from "../auth/_components/utils"
const inviteCode = $params["?code"] const inviteCode = $params["?code"]
let password, error let form
let formData = {}
let onboarding = false
let errors = {}
$: company = $organisation.company || "Budibase" $: company = $organisation.company || "Budibase"
async function acceptInvite() { async function acceptInvite() {
form.validate()
if (Object.keys(errors).length > 0) {
return
}
onboarding = true
try { try {
await users.acceptInvite(inviteCode, password) const { password, firstName, lastName } = formData
await users.acceptInvite(inviteCode, password, firstName, lastName)
notifications.success("Invitation accepted successfully") notifications.success("Invitation accepted successfully")
$goto("../auth/login") await login()
} catch (error) { } catch (error) {
notifications.error(error.message) notifications.error(error.message)
onboarding = false
}
}
async function getInvite() {
try {
const invite = await users.fetchInvite(inviteCode)
if (invite?.email) {
formData.email = invite?.email
}
} catch (error) {
notifications.error(error.message)
}
}
async function login() {
try {
await auth.login({
username: formData.email.trim(),
password: formData.password.trim(),
})
notifications.success("Logged in successfully")
$goto("../portal")
} catch (err) {
notifications.error(err.message ? err.message : "Invalid credentials") //not likely, considering.
} }
} }
onMount(async () => { onMount(async () => {
try { try {
await organisation.init() await organisation.init()
await getInvite()
} catch (error) { } catch (error) {
notifications.error("Error getting org config") notifications.error("Error getting invite config")
} }
}) })
</script> </script>
<section> <TestimonialPage>
<div class="container"> <Layout gap="M" noPadding>
<Layout> <img alt="logo" src={$organisation.logoUrl || Logo} />
<img alt="logo" src={$organisation.logoUrl || Logo} /> <Layout gap="XS" noPadding>
<Layout gap="XS" justifyItems="center" noPadding> <Heading size="M">Join {company}</Heading>
<Heading size="M">Invitation to {company}</Heading> <Body size="M">Create your account to access your budibase apps!</Body>
<Body textAlign="center" size="M">
Please enter a password to get started.
</Body>
</Layout>
<PasswordRepeatInput bind:error bind:password />
<Button disabled={error} cta on:click={acceptInvite}>
Accept invite
</Button>
</Layout> </Layout>
</div>
</section> <Layout gap="S" noPadding>
<FancyForm bind:this={form}>
<FancyInput
label="Email"
value={formData.email}
disabled={true}
error={errors.email}
/>
<FancyInput
label="First Name"
value={formData.firstName}
on:change={e => {
formData = {
...formData,
firstName: e.detail,
}
}}
validate={() => {
handleError(() => {
return {
firstName: !formData.firstName
? "Please enter your first name"
: undefined,
}
}, errors)
}}
error={errors.firstName}
disabled={onboarding}
/>
<FancyInput
label="Last Name (Optional)"
value={formData.lastName}
on:change={e => {
formData = {
...formData,
lastName: e.detail,
}
}}
disabled={onboarding}
/>
<FancyInput
label="Password"
value={formData.password}
type="password"
on:change={e => {
formData = {
...formData,
password: e.detail,
}
}}
validate={() => {
handleError(() => {
let err = {}
err["password"] = !formData.password
? "Please enter a password"
: undefined
err["confirmationPassword"] =
!passwordsMatch(
formData.password,
formData.confirmationPassword
) && formData.confirmationPassword
? "Passwords must match"
: undefined
return err
}, errors)
}}
error={errors.password}
disabled={onboarding}
/>
<FancyInput
label="Repeat Password"
value={formData.confirmationPassword}
type="password"
on:change={e => {
formData = {
...formData,
confirmationPassword: e.detail,
}
}}
validate={() => {
handleError(() => {
return {
confirmationPassword:
!passwordsMatch(
formData.password,
formData.confirmationPassword
) && formData.password
? "Passwords must match"
: undefined,
}
}, errors)
}}
error={errors.confirmationPassword}
disabled={onboarding}
/>
</FancyForm>
</Layout>
<div>
<Button
disabled={Object.keys(errors).length > 0 || onboarding}
cta
on:click={acceptInvite}
>
Create account
</Button>
</div>
</Layout>
</TestimonialPage>
<style> <style>
section {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.container {
margin: 0 auto;
width: 300px;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
}
img { img {
width: 40px; width: 40px;
margin: 0 auto;
} }
</style> </style>

View File

@ -29,13 +29,19 @@ export function createUsersStore() {
async function invite(payload) { async function invite(payload) {
return API.inviteUsers(payload) return API.inviteUsers(payload)
} }
async function acceptInvite(inviteCode, password) { async function acceptInvite(inviteCode, password, firstName, lastName) {
return API.acceptInvite({ return API.acceptInvite({
inviteCode, inviteCode,
password, password,
firstName,
lastName,
}) })
} }
async function fetchInvite(inviteCode) {
return API.getUserInvite(inviteCode)
}
async function create(data) { async function create(data) {
let mappedUsers = data.users.map(user => { let mappedUsers = data.users.map(user => {
const body = { const body = {
@ -101,6 +107,7 @@ export function createUsersStore() {
fetch, fetch,
invite, invite,
acceptInvite, acceptInvite,
fetchInvite,
create, create,
save, save,
bulkDelete, bulkDelete,

View File

@ -146,6 +146,16 @@ export const buildUserEndpoints = API => ({
}) })
}, },
/**
* Retrieves the invitation associated with a provided code.
* @param code The unique code for the target invite
*/
getUserInvite: async code => {
return await API.get({
url: `/api/global/users/invite/${code}`,
})
},
/** /**
* Invites multiple users to the current tenant. * Invites multiple users to the current tenant.
* @param users An array of users to invite * @param users An array of users to invite
@ -168,13 +178,17 @@ export const buildUserEndpoints = API => ({
* Accepts an invite to join the platform and creates a user. * Accepts an invite to join the platform and creates a user.
* @param inviteCode the invite code sent in the email * @param inviteCode the invite code sent in the email
* @param password the password for the newly created user * @param password the password for the newly created user
* @param firstName the first name of the new user
* @param lastName the last name of the new user
*/ */
acceptInvite: async ({ inviteCode, password }) => { acceptInvite: async ({ inviteCode, password, firstName, lastName }) => {
return await API.post({ return await API.post({
url: "/api/global/users/invite/accept", url: "/api/global/users/invite/accept",
body: { body: {
inviteCode, inviteCode,
password, password,
firstName,
lastName,
}, },
}) })
}, },

View File

@ -210,6 +210,19 @@ export const inviteMultiple = async (ctx: any) => {
ctx.body = await sdk.users.invite(request) ctx.body = await sdk.users.invite(request)
} }
export const checkInvite = async (ctx: any) => {
const { code } = ctx.params
let invite
try {
invite = await checkInviteCode(code, false)
} catch (e) {
ctx.throw(400, "There was a problem with the invite")
}
ctx.body = {
email: invite.email,
}
}
export const inviteAccept = async (ctx: any) => { export const inviteAccept = async (ctx: any) => {
const { inviteCode, password, firstName, lastName } = ctx.request.body const { inviteCode, password, firstName, lastName } = ctx.request.body
try { try {

View File

@ -62,6 +62,10 @@ const PUBLIC_ENDPOINTS = [
route: "/api/system/restored", route: "/api/system/restored",
method: "POST", method: "POST",
}, },
{
route: "/api/global/users/invite",
method: "GET",
},
] ]
const NO_TENANCY_ENDPOINTS = [ const NO_TENANCY_ENDPOINTS = [

View File

@ -38,6 +38,13 @@ function buildInviteMultipleValidation() {
)) ))
} }
function buildInviteLookupValidation() {
// prettier-ignore
return auth.joiValidator.params(Joi.object({
code: Joi.string().required()
}).unknown(true))
}
const createUserAdminOnly = (ctx: any, next: any) => { const createUserAdminOnly = (ctx: any, next: any) => {
if (!ctx.request.body._id) { if (!ctx.request.body._id) {
return auth.adminOnly(ctx, next) return auth.adminOnly(ctx, next)
@ -51,6 +58,8 @@ function buildInviteAcceptValidation() {
return auth.joiValidator.body(Joi.object({ return auth.joiValidator.body(Joi.object({
inviteCode: Joi.string().required(), inviteCode: Joi.string().required(),
password: Joi.string().required(), password: Joi.string().required(),
firstName: Joi.string().required(),
lastName: Joi.string().optional(),
}).required().unknown(true)) }).required().unknown(true))
} }
@ -91,6 +100,11 @@ router
) )
// non-global endpoints // non-global endpoints
.get(
"/api/global/users/invite/:code",
buildInviteLookupValidation(),
controller.checkInvite
)
.post( .post(
"/api/global/users/invite/accept", "/api/global/users/invite/accept",
buildInviteAcceptValidation(), buildInviteAcceptValidation(),