Merge pull request #1501 from Budibase/feature/forgot-password

Forgotten password flow (builder)
This commit is contained in:
Michael Drury 2021-05-18 16:39:06 +01:00 committed by GitHub
commit 85e6472c3a
18 changed files with 317 additions and 174 deletions

View File

@ -6,10 +6,11 @@
export let gap = "M" export let gap = "M"
export let noGap = false export let noGap = false
export let alignContent = "normal" export let alignContent = "normal"
export let justifyItems = "stretch"
</script> </script>
<div <div
style="align-content:{alignContent};" style="align-content:{alignContent};justify-items:{justifyItems};"
class:horizontal class:horizontal
class="container paddingX-{!noPadding && paddingX} paddingY-{!noPadding && class="container paddingX-{!noPadding && paddingX} paddingY-{!noPadding &&
paddingY} gap-{!noGap && gap}" paddingY} gap-{!noGap && gap}"

View File

@ -4,9 +4,11 @@
export let size = "M" export let size = "M"
export let serif = false export let serif = false
export let noPadding = false export let noPadding = false
export let textAlign
</script> </script>
<p <p
style="{textAlign ? `text-align:${textAlign}` : ``}"
class:noPadding class:noPadding
class="spectrum-Body spectrum-Body--size{size}" class="spectrum-Body spectrum-Body--size{size}"
class:spectrum-Body--serif={serif} class:spectrum-Body--serif={serif}

View File

@ -3,8 +3,14 @@
// Sizes // Sizes
export let size = "M" export let size = "M"
export let textAlign
export let noPadding = false
</script> </script>
<h1 class="spectrum-Heading spectrum-Heading--size{size}"> <h1
style="{textAlign ? `text-align:${textAlign}` : ``}"
class:noPadding
class="spectrum-Heading spectrum-Heading--size{size}"
>
<slot /> <slot />
</h1> </h1>

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@ -0,0 +1,43 @@
<script>
import { Layout, Input } from "@budibase/bbui"
import {
createValidationStore,
requiredValidator,
} from "../../../helpers/validation"
export let password
export let error
const [firstPassword, passwordError, firstTouched] = createValidationStore(
"",
requiredValidator
)
const [repeatPassword, _, repeatTouched] = createValidationStore(
"",
requiredValidator
)
$: password = $firstPassword
$: error =
!$firstPassword ||
!$firstTouched ||
!$repeatTouched ||
$firstPassword !== $repeatPassword
</script>
<Layout gap="XS" noPadding>
<Input
label="Password"
type="password"
error={$firstTouched && $passwordError}
bind:value={$firstPassword}
/>
<Input
label="Repeat Password"
type="password"
error={$repeatTouched &&
$firstPassword !== $repeatPassword &&
"Passwords must match"}
bind:value={$repeatPassword}
/>
</Layout>

View File

@ -0,0 +1,60 @@
<script>
import {
Input,
Button,
Layout,
Body,
Heading,
notifications,
} from "@budibase/bbui"
import { auth } from "stores/backend"
let email = ""
async function forgot() {
try {
await auth.forgotPassword(email)
notifications.success("Email sent - please check your inbox")
} catch (err) {
notifications.error("Unable to send reset password link")
}
}
</script>
<div class="login">
<div class="main">
<Layout>
<Layout noPadding justifyItems="center">
<img src="https://i.imgur.com/ZKyklgF.png" />
</Layout>
<Layout gap="XS" noPadding>
<Heading textAlign="center">Forgotten your password?</Heading>
<Body size="S" textAlign="center">
No problem! Just enter your account's email address and we'll send
you a link to reset it.
</Body>
<Input label="Email" bind:value={email} />
</Layout>
<Button cta on:click={forgot} disabled={!email}>Reset your password</Button>
</Layout>
</div>
</div>
<style>
.login {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.main {
width: 300px;
}
img {
width: 48px;
}
</style>

View File

@ -0,0 +1,43 @@
<script>
import { Link } from "@budibase/bbui"
import GoogleLogo from "/assets/google-logo.png"
</script>
<div class="outer">
<Link target="_blank" href="/api/admin/auth/google">
<div class="inner">
<img src={GoogleLogo} alt="google icon" />
<p>Sign in with Google</p>
</div>
</Link>
</div>
<style>
.outer {
border: 1px solid #494949;
border-radius: 4px;
width: 100%;
background-color: var(--background-alt);
}
.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;
}
.outer :global(a) {
text-decoration: none;
font-weight: 500;
font-size: var(--font-size-m);
color: #fff;
}
</style>

View File

@ -2,11 +2,15 @@
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import { import {
notifications, notifications,
Link,
Input, Input,
Modal, Button,
ModalContent, Divider,
ActionButton,
Layout,
Body,
Heading,
} from "@budibase/bbui" } from "@budibase/bbui"
import GoogleButton from "./GoogleButton.svelte"
import { auth } from "stores/backend" import { auth } from "stores/backend"
let username = "" let username = ""
@ -27,33 +31,50 @@
} }
</script> </script>
<Modal fixed> <div class="login">
<ModalContent <div class="main">
size="M" <Layout>
title="Log In" <Layout noPadding justifyItems="center">
onConfirm={login} <img src="https://i.imgur.com/ZKyklgF.png" />
confirmText="Log In" <Heading>Sign in to Budibase</Heading>
showCancelButton={false} </Layout>
showCloseIcon={false} <GoogleButton />
> <Layout gap="XS" noPadding>
<Divider noGrid />
<Body size="S" textAlign="center">Sign in with email</Body>
<Input label="Email" bind:value={username} /> <Input label="Email" bind:value={username} />
<Input label="Password" type="password" on:change bind:value={password} /> <Input
<div class="footer" slot="footer"> label="Password"
<Link target="_blank" href="/api/admin/auth/google"> type="password"
Sign In With Google on:change
</Link> bind:value={password}
/>
</Layout>
<Layout gap="S" noPadding>
<Button cta on:click={login}>Sign in to Budibase</Button>
<ActionButton quiet on:click={() => $goto("./forgot")}>
Forgot password?
</ActionButton>
</Layout>
</Layout>
</div>
</div> </div>
</ModalContent>
</Modal>
<style> <style>
.footer { .login {
width: 100%;
height: 100%;
display: flex; display: flex;
flex-direction: row; flex-direction: column;
justify-content: flex-end; justify-content: center;
align-items: center; align-items: center;
} }
.footer :global(a) {
margin-right: var(--spectrum-global-dimension-static-size-200); .main {
width: 300px;
}
img {
width: 48px;
} }
</style> </style>

View File

@ -0,0 +1,58 @@
<script>
import { Button, Layout, Body, Heading, notifications } from "@budibase/bbui"
import PasswordRepeatInput from "components/common/users/PasswordRepeatInput.svelte"
import { params, goto } from "@roxi/routify"
import { auth } from "stores/backend"
const resetCode = $params["?code"]
let password, error
async function reset() {
try {
await auth.resetPassword(password, resetCode)
notifications.success("Password reset successfully")
// send them to login if reset successful
$goto("./login")
} catch (err) {
notifications.error("Unable to reset password")
}
}
</script>
<div class="login">
<div class="main">
<Layout>
<Layout noPadding justifyItems="center">
<img src="https://i.imgur.com/ZKyklgF.png" />
</Layout>
<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 || !resetCode}>Reset your password</Button>
</Layout>
</div>
</div>
<style>
.login {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.main {
width: 260px;
}
img {
width: 48px;
}
</style>

View File

@ -1,6 +1,6 @@
<script> <script>
import { onMount } from "svelte" import { onMount } from "svelte"
import { page, goto } from "@roxi/routify" import { goto, isActive } from "@roxi/routify"
import { auth } from "stores/backend" import { auth } from "stores/backend"
import { admin } from "stores/portal" import { admin } from "stores/portal"
@ -23,10 +23,11 @@
// Redirect to log in at any time if the user isn't authenticated // Redirect to log in at any time if the user isn't authenticated
$: { $: {
if ( if (
!$page.path.includes("/builder/invite") &&
loaded && loaded &&
hasAdminUser && hasAdminUser &&
!$auth.user !$auth.user &&
!$isActive("./auth") &&
!$isActive("./invite")
) { ) {
$goto("./auth/login") $goto("./auth/login")
} }

View File

@ -0,0 +1,5 @@
<script>
import ForgotForm from "components/login/ForgotForm.svelte"
</script>
<ForgotForm />

View File

@ -0,0 +1,5 @@
<script>
import ResetForm from "components/login/ResetForm.svelte"
</script>
<ResetForm />

View File

@ -1,29 +1,15 @@
<script> <script>
import { import { Layout, Heading, Body, Button, notifications } from "@budibase/bbui"
Layout,
Heading,
Body,
Input,
Button,
notifications,
} from "@budibase/bbui"
import { goto, params } from "@roxi/routify" import { goto, params } from "@roxi/routify"
import { createValidationStore, requiredValidator } from "helpers/validation" import PasswordRepeatInput from "components/common/users/PasswordRepeatInput.svelte"
import { users } from "stores/portal" import { users } from "stores/portal"
const [password, passwordError, passwordTouched] = createValidationStore(
"",
requiredValidator
)
const [repeat, _, repeatTouched] = createValidationStore(
"",
requiredValidator
)
const inviteCode = $params["?code"] const inviteCode = $params["?code"]
let password, error
async function acceptInvite() { async function acceptInvite() {
try { try {
const res = await users.acceptInvite(inviteCode, $password) const res = await users.acceptInvite(inviteCode, password)
if (!res) { if (!res) {
throw new Error(res.message) throw new Error(res.message)
} }
@ -40,33 +26,15 @@
<Layout gap="XS"> <Layout gap="XS">
<img src="https://i.imgur.com/ZKyklgF.png" /> <img src="https://i.imgur.com/ZKyklgF.png" />
</Layout> </Layout>
<div class="center">
<Layout gap="XS"> <Layout gap="XS">
<Heading size="M">Accept Invitation</Heading> <Heading textAlign="center" size="M">Accept Invitation</Heading>
<Body size="M">Please enter a password to setup your user.</Body> <Body textAlign="center" size="S"
</Layout> >Please enter a password to setup your user.</Body
</div> >
<Layout gap="XS"> <PasswordRepeatInput bind:error bind:password />
<Input
label="Password"
type="password"
error={$passwordTouched && $passwordError}
bind:value={$password}
/>
<Input
label="Repeat Password"
type="password"
error={$repeatTouched &&
$password !== $repeat &&
"Passwords must match"}
bind:value={$repeat}
/>
</Layout> </Layout>
<Layout gap="S"> <Layout gap="S">
<Button <Button disabled={error} cta on:click={acceptInvite}>Accept invite</Button
disabled={!$passwordTouched || !$repeatTouched || $password !== $repeat}
cta
on:click={acceptInvite}>Accept invite</Button
> >
</Layout> </Layout>
</div> </div>
@ -87,9 +55,6 @@
justify-content: flex-start; justify-content: flex-start;
align-items: stretch; align-items: stretch;
} }
.center {
text-align: center;
}
img { img {
width: 40px; width: 40px;
margin: 0 auto; margin: 0 auto;

View File

@ -33,6 +33,25 @@ export function createAuthStore() {
await response.json() await response.json()
store.update(state => ({ ...state, user: null })) store.update(state => ({ ...state, user: null }))
}, },
forgotPassword: async email => {
const response = await api.post(`/api/admin/auth/reset`, {
email,
})
if (response.status !== 200) {
throw "Unable to send email with reset link"
}
await response.json()
},
resetPassword: async (password, code) => {
const response = await api.post(`/api/admin/auth/reset/update`, {
password,
resetCode: code,
})
if (response.status !== 200) {
throw "Unable to reset password"
}
await response.json()
},
createUser: async user => { createUser: async user => {
const response = await api.post(`/api/admin/users`, user) const response = await api.post(`/api/admin/users`, user)
if (response.status !== 200) { if (response.status !== 200) {

View File

@ -54,10 +54,13 @@ exports.reset = async ctx => {
} }
try { try {
const user = await getGlobalUserByEmail(email) const user = await getGlobalUserByEmail(email)
// only if user exists, don't error though if they don't
if (user) {
await sendEmail(email, EmailTemplatePurpose.PASSWORD_RECOVERY, { await sendEmail(email, EmailTemplatePurpose.PASSWORD_RECOVERY, {
user, user,
subject: "{{ company }} platform password reset", subject: "{{ company }} platform password reset",
}) })
}
} catch (err) { } catch (err) {
// don't throw any kind of error to the user, this might give away something // don't throw any kind of error to the user, this might give away something
} }

View File

@ -1,93 +0,0 @@
const authPkg = require("@budibase/auth")
const { google } = require("@budibase/auth/src/middleware")
const { Configs } = require("../../constants")
const CouchDB = require("../../db")
const { clearCookie } = authPkg.utils
const { Cookies } = authPkg.constants
const { passport } = authPkg.auth
const GLOBAL_DB = authPkg.StaticDatabases.GLOBAL.name
exports.authenticate = async (ctx, next) => {
return passport.authenticate("local", async (err, user) => {
if (err) {
return ctx.throw(403, "Unauthorized")
}
const expires = new Date()
expires.setDate(expires.getDate() + 1)
if (!user) {
return ctx.throw(403, "Unauthorized")
}
ctx.cookies.set(Cookies.Auth, user.token, {
expires,
path: "/",
httpOnly: false,
overwrite: true,
})
delete user.token
ctx.body = { user }
})(ctx, next)
}
exports.logout = async ctx => {
clearCookie(ctx, Cookies.Auth)
ctx.body = { message: "User logged out" }
}
/**
* The initial call that google authentication makes to take you to the google login screen.
* On a successful login, you will be redirected to the googleAuth callback route.
*/
exports.googlePreAuth = async (ctx, next) => {
const db = new CouchDB(GLOBAL_DB)
const config = await authPkg.db.getScopedFullConfig(db, {
type: Configs.GOOGLE,
group: ctx.query.group,
})
const strategy = await google.strategyFactory(config)
return passport.authenticate(strategy, {
scope: ["profile", "email"],
})(ctx, next)
}
exports.googleAuth = async (ctx, next) => {
const db = new CouchDB(GLOBAL_DB)
const config = await authPkg.db.getScopedFullConfig(db, {
type: Configs.GOOGLE,
group: ctx.query.group,
})
const strategy = await google.strategyFactory(config)
return passport.authenticate(
strategy,
{ successRedirect: "/", failureRedirect: "/error" },
async (err, user) => {
if (err) {
return ctx.throw(403, "Unauthorized")
}
const expires = new Date()
expires.setDate(expires.getDate() + 1)
if (!user) {
return ctx.throw(403, "Unauthorized")
}
ctx.cookies.set(Cookies.Auth, user.token, {
expires,
path: "/",
httpOnly: false,
overwrite: true,
})
ctx.redirect("/")
}
)(ctx, next)
}

View File

@ -117,6 +117,10 @@ async function getSmtpConfiguration(db, groupId = null) {
* @return {Promise<boolean>} returns true if there is a configuration that can be used. * @return {Promise<boolean>} returns true if there is a configuration that can be used.
*/ */
exports.isEmailConfigured = async (groupId = null) => { exports.isEmailConfigured = async (groupId = null) => {
// when "testing" simply return true
if (TEST_MODE) {
return true
}
const db = new CouchDB(GLOBAL_DB) const db = new CouchDB(GLOBAL_DB)
const config = await getSmtpConfiguration(db, groupId) const config = await getSmtpConfiguration(db, groupId)
return config != null return config != null

View File

@ -35,7 +35,7 @@ exports.getSettingsTemplateContext = async (purpose, code = null) => {
case EmailTemplatePurpose.PASSWORD_RECOVERY: case EmailTemplatePurpose.PASSWORD_RECOVERY:
context[InternalTemplateBindings.RESET_CODE] = code context[InternalTemplateBindings.RESET_CODE] = code
context[InternalTemplateBindings.RESET_URL] = checkSlashesInUrl( context[InternalTemplateBindings.RESET_URL] = checkSlashesInUrl(
`${URL}/reset?code=${code}` `${URL}/builder/auth/reset?code=${code}`
) )
break break
case EmailTemplatePurpose.INVITATION: case EmailTemplatePurpose.INVITATION: