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];