diff --git a/.env.test b/.env.test index eece736..6212e03 100644 --- a/.env.test +++ b/.env.test @@ -1,4 +1,4 @@ -BASE_URL=http://localhost:3000 +NEXT_PUBLIC_BASE_URL=http://localhost:3000 PGHOST= PGPORT=5432 PGUSER= diff --git a/src/app/_auth/login_signup.module.css b/src/app/_auth/login_signup.module.css index c637ba7..c6d123c 100644 --- a/src/app/_auth/login_signup.module.css +++ b/src/app/_auth/login_signup.module.css @@ -5,8 +5,8 @@ flex-direction: column; justify-content: center; height: 100%; - width: calc(var(--gridBaseline) * 35); - max-width: 90%; + width: calc(var(--gridBaseline) * 50); + max-width: 80%; } .submitContainer { @@ -16,3 +16,9 @@ align-items: center; justify-content: space-between; } + +.accountActions { + display: flex; + flex-direction: column; + gap: var(--gridBaseline); +} diff --git a/src/app/_auth/login_signup.tsx b/src/app/_auth/login_signup.tsx index fa3272c..9b52d17 100644 --- a/src/app/_auth/login_signup.tsx +++ b/src/app/_auth/login_signup.tsx @@ -55,9 +55,14 @@ export const LoginSignup = observer( onSubmit={login} />
- - Signup instead - +
+ + Signup instead + + + Forgot password? + +
diff --git a/src/app/password/reset/page.tsx b/src/app/password/reset/page.tsx new file mode 100644 index 0000000..03a9b36 --- /dev/null +++ b/src/app/password/reset/page.tsx @@ -0,0 +1,45 @@ +'use client'; + +import { observer } from 'mobx-react-lite'; +import { useState } from 'react'; +import { Button } from 'ui/base/button/button'; +import { FormError } from 'ui/base/form/form_error'; +import { RouteLink } from 'ui/base/text/link'; +import { Textbox } from 'ui/base/textbox/textbox'; +import { RoutePath, routeFor } from 'utils/routes'; +import styles from './reset_password.module.css'; +import { ResetPasswordPresenter, ResetPasswordStore } from './reset_password_presenter'; + +export default observer(() => { + const [store] = useState(new ResetPasswordStore()); + const presenter = new ResetPasswordPresenter(store); + + if (store.success) { + return ( +
+

Check your email for a password reset link.

+ Back to login +
+ ); + } + + return ( +
+

Enter your email address and we'll send you a link to reset your password.

+ +
+ Back to login + +
+ +
+ ); +}); diff --git a/src/app/password/reset/reset_password.module.css b/src/app/password/reset/reset_password.module.css new file mode 100644 index 0000000..ffd1f77 --- /dev/null +++ b/src/app/password/reset/reset_password.module.css @@ -0,0 +1,18 @@ +.resetPassword { + display: flex; + gap: var(--gridBaseline); + margin: auto; + flex-direction: column; + justify-content: center; + height: 100%; + width: calc(var(--gridBaseline) * 50); + max-width: 80%; +} + +.submitContainer { + display: flex; + width: 100%; + margin-top: var(--gridBaseline); + align-items: center; + justify-content: space-between; +} diff --git a/src/app/password/reset/reset_password_presenter.ts b/src/app/password/reset/reset_password_presenter.ts new file mode 100644 index 0000000..7ce6303 --- /dev/null +++ b/src/app/password/reset/reset_password_presenter.ts @@ -0,0 +1,64 @@ +import { action, makeObservable, observable } from 'mobx'; +import { createClient } from 'services/session/supabase_client'; +import { FormPresenter, FormStore } from 'ui/base/form/form_presenter'; +import { RoutePath, routeFor } from 'utils/routes'; + +type ResetPasswordField = 'email' | 'form'; + +export class ResetPasswordStore extends FormStore { + submitting = false; + email = ''; + success = false; + + constructor() { + super(); + makeObservable(this, { + email: observable.ref, + submitting: observable.ref, + success: observable.ref, + }); + } +} + +export class ResetPasswordPresenter extends FormPresenter { + constructor(private readonly store: ResetPasswordStore) { + super(store); + makeObservable(this, { + onChangeEmail: action.bound, + requestReset: action.bound, + setSubmitting: action.bound, + setSuccess: action.bound, + }); + + console.log( + `${process.env.NEXT_PUBLIC_BASE_URL}${routeFor([RoutePath.PASSWORD, RoutePath.RESET, RoutePath.UPDATE])}` + ); + } + + onChangeEmail = (value: string) => (this.store.email = value); + private setSubmitting = (value: boolean) => (this.store.submitting = value); + private setSuccess = (value: boolean) => (this.store.success = value); + + async requestReset() { + this.clearErrors(); + const email = this.store.email; + const errors = [ + ...this.checkEmailFields(['email', email]), + ...this.checkRequiredFields(['email', email]), + ]; + if (errors.length) { + return; + } + + this.setSubmitting(true); + const redirectTo = `${process.env.NEXT_PUBLIC_BASE_URL}${routeFor([RoutePath.PASSWORD, RoutePath.RESET, RoutePath.UPDATE])}`; + const { error } = await createClient().auth.resetPasswordForEmail(email, { redirectTo }); + this.setSubmitting(false); + + if (error) { + this.pushErrors(['form'], error.message); + } else { + this.setSuccess(true); + } + } +} diff --git a/src/app/password/reset/update/page.tsx b/src/app/password/reset/update/page.tsx new file mode 100644 index 0000000..89b0974 --- /dev/null +++ b/src/app/password/reset/update/page.tsx @@ -0,0 +1,52 @@ +'use client'; + +import { observer } from 'mobx-react-lite'; +import { useState } from 'react'; +import { Button } from 'ui/base/button/button'; +import { FormError } from 'ui/base/form/form_error'; +import { Textbox } from 'ui/base/textbox/textbox'; +import styles from './update_password.module.css'; +import { UpdatePasswordPresenter, UpdatePasswordStore } from './update_password_presenter'; + +export default observer(() => { + const [store] = useState(new UpdatePasswordStore()); + const presenter = new UpdatePasswordPresenter(store); + + if (store.success) { + return ( +
+

Your password has been updated. Redirecting to home...

+
+ ); + } + + return ( +
+

Enter your new password.

+ + +
+ +
+ +
+ ); +}); diff --git a/src/app/password/reset/update/update_password.module.css b/src/app/password/reset/update/update_password.module.css new file mode 100644 index 0000000..4cbef62 --- /dev/null +++ b/src/app/password/reset/update/update_password.module.css @@ -0,0 +1,18 @@ +.updatePassword { + display: flex; + gap: var(--gridBaseline); + margin: auto; + flex-direction: column; + justify-content: center; + height: 100%; + width: calc(var(--gridBaseline) * 50); + max-width: 80%; +} + +.submitContainer { + display: flex; + width: 100%; + margin-top: var(--gridBaseline); + align-items: center; + justify-content: flex-end; +} diff --git a/src/app/password/reset/update/update_password_presenter.ts b/src/app/password/reset/update/update_password_presenter.ts new file mode 100644 index 0000000..00f7b33 --- /dev/null +++ b/src/app/password/reset/update/update_password_presenter.ts @@ -0,0 +1,72 @@ +import { action, makeObservable, observable } from 'mobx'; +import { createClient } from 'services/session/supabase_client'; +import { FormPresenter, FormStore } from 'ui/base/form/form_presenter'; +import { RoutePath, routeFor } from 'utils/routes'; + +type UpdatePasswordField = 'password' | 'confirmPassword' | 'form'; + +export class UpdatePasswordStore extends FormStore { + submitting = false; + password = ''; + confirmPassword = ''; + success = false; + + constructor() { + super(); + makeObservable(this, { + password: observable.ref, + confirmPassword: observable.ref, + submitting: observable.ref, + success: observable.ref, + }); + } +} + +export class UpdatePasswordPresenter extends FormPresenter { + constructor(private readonly store: UpdatePasswordStore) { + super(store); + makeObservable(this, { + onChangePassword: action.bound, + onChangeConfirmPassword: action.bound, + updatePassword: action.bound, + setSubmitting: action.bound, + setSuccess: action.bound, + }); + } + + onChangePassword = (value: string) => (this.store.password = value); + onChangeConfirmPassword = (value: string) => (this.store.confirmPassword = value); + private setSubmitting = (value: boolean) => (this.store.submitting = value); + private setSuccess = (value: boolean) => (this.store.success = value); + + async updatePassword() { + this.clearErrors(); + const password = this.store.password; + const confirmPassword = this.store.confirmPassword; + const errors = [ + ...this.checkPasswordRestrictionFields(['password', password]), + ...this.checkRequiredFields(['password', password], ['confirmPassword', confirmPassword]), + ...this.checkPasswordConfirmFields( + ['password', password], + ['confirmPassword', confirmPassword] + ), + ]; + if (errors.length) { + return; + } + + this.setSubmitting(true); + const { error } = await createClient().auth.updateUser({ password }); + this.setSubmitting(false); + + if (error) { + this.pushErrors(['form'], error.message); + } else { + this.setSuccess(true); + // Redirect to login after a short delay + setTimeout(() => { + window.location.href = routeFor([RoutePath.MAP_LIST]); + }, 2000); + } + } +} diff --git a/src/services/env.ts b/src/services/env.ts index b89a0ca..1f5a92e 100644 --- a/src/services/env.ts +++ b/src/services/env.ts @@ -42,7 +42,7 @@ export function getEnvVars() { function createEnvVars(): EnvVars { const envVars: { [K in keyof EnvVars]: EnvVars[K] | undefined } = { - baseUrl: process.env.BASE_URL, + baseUrl: process.env.NEXT_PUBLIC_BASE_URL, pgHost: process.env.PGHOST, pgPort: Number(process.env.PGPORT || undefined), pgDatabase: process.env.PGDATABASE, diff --git a/src/session/session_presenter.ts b/src/session/session_presenter.ts index 9d116a7..b364c01 100644 --- a/src/session/session_presenter.ts +++ b/src/session/session_presenter.ts @@ -1,6 +1,7 @@ import { Api } from 'app/api/api'; import { makeAutoObservable, runInAction } from 'mobx'; import { User } from 'schema/users'; +import { createClient } from 'services/session/supabase_client'; export class SessionStore { hasLoaded = false; @@ -20,6 +21,10 @@ export class SessionPresenter { } async maybeLoadSession() { + // TODO: rewrite sessionstore and presenter in the client to just use Supabase Auth + const client = createClient(); + await client.auth.initialize(); + if (this.store.hasLoaded) { return; } diff --git a/src/ui/base/form/form_presenter.ts b/src/ui/base/form/form_presenter.ts index aaa7704..2f85b70 100644 --- a/src/ui/base/form/form_presenter.ts +++ b/src/ui/base/form/form_presenter.ts @@ -61,6 +61,18 @@ export class FormPresenter { return errorFields; } + protected checkPasswordConfirmFields( + password: readonly [Field, string], + confirmPassword: readonly [Field, string] + ) { + const matches = password[1] === confirmPassword[1]; + if (!matches) { + this.pushErrors([confirmPassword[0]], 'Password does not match.'); + return [confirmPassword[0]]; + } + return []; + } + protected clearErrors() { this._store.errors.clear(); } diff --git a/src/ui/nav_bar/nav_bar.module.css b/src/ui/nav_bar/nav_bar.module.css index c7c1bba..e6a3f2b 100644 --- a/src/ui/nav_bar/nav_bar.module.css +++ b/src/ui/nav_bar/nav_bar.module.css @@ -19,14 +19,10 @@ align-items: center; } -.userStatus { - margin-right: calc(var(--gridBaseline) * 2); -} - .menuItem { margin: 0 var(--gridBaseline); } .themeToggleContainer { - margin-right: calc(var(--gridBaseline) * 2); + margin-right: var(--gridBaseline); } diff --git a/src/ui/nav_bar/nav_bar.tsx b/src/ui/nav_bar/nav_bar.tsx index 77bfb1b..cc5610e 100644 --- a/src/ui/nav_bar/nav_bar.tsx +++ b/src/ui/nav_bar/nav_bar.tsx @@ -1,12 +1,37 @@ -import { getUserSession } from 'services/session/session'; +'use client'; + +import { usePathname } from 'next/navigation'; +import { useEffect, useState } from 'react'; +import { UserSession } from 'schema/users'; +import { createClient } from 'services/session/supabase_client'; import { RouteLink } from 'ui/base/text/link'; import { T } from 'ui/base/text/text'; import { ThemeToggle } from 'ui/base/theme/theme_toggle'; -import { routeFor, RoutePath } from 'utils/routes'; +import { RoutePath, routeFor } from 'utils/routes'; import styles from './nav_bar.module.css'; -export const NavBar = async () => { - const user = await getUserSession(); +export const NavBar = () => { + const [user, setUser] = useState(null); + useEffect(() => { + createClient() + .auth.getUser() + .then((user) => { + if (user.error || !user.data.user.email) { + return undefined; + } + const metadata = user.data.user.user_metadata; + setUser({ + id: metadata.id, + email: user.data.user.email, + username: metadata.username, + }); + }); + }, [setUser]); + + const pathname = usePathname(); + const showUserActions = ![ + routeFor([RoutePath.PASSWORD, RoutePath.RESET, RoutePath.UPDATE]), + ].includes(pathname); return (
@@ -16,34 +41,40 @@ export const NavBar = async () => {
-
- - - Install instructions - - - {user == null ? ( + {showUserActions ? ( + <> - Login - - - ) : ( - - - Submit map{' '} - | Logged in as {user.username} ({user.email}) |{' '} - - Settings - {' '} - |{' '} - - Logout + + Install instructions - )} -
+ {user == null ? ( + + + Login + + + ) : ( + + + + Submit map + {' '} + | Logged in as {user.username} ({user.email}) |{' '} + + Settings + {' '} + |{' '} + + Logout + + + + )} + + ) : null}
diff --git a/src/utils/routes.ts b/src/utils/routes.ts index 533b99e..e8cb5ae 100644 --- a/src/utils/routes.ts +++ b/src/utils/routes.ts @@ -3,6 +3,9 @@ export const enum RoutePath { MAP = 'map', LOGIN = 'login', LOGOUT = 'logout', + PASSWORD = 'password', + RESET = 'reset', + UPDATE = 'update', SETTINGS = 'settings', SIGNUP = 'signup', SUBMIT = 'submit', @@ -15,6 +18,8 @@ export type RouteSegments = | [RoutePath.MAP_LIST] | [RoutePath.LOGIN] | [RoutePath.LOGOUT] + | [RoutePath.PASSWORD, RoutePath.RESET] + | [RoutePath.PASSWORD, RoutePath.RESET, RoutePath.UPDATE] | [RoutePath.SETTINGS] | [RoutePath.SIGNUP] | [RoutePath.INSTRUCTIONS];