From 77b3bcf3a6e8c8020b85e91fcddf816a5bd6f1d4 Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Sat, 4 Apr 2026 16:40:40 +0000 Subject: [PATCH] Autosave: 20260404-164039 --- frontend/src/components/FormField.tsx | 12 +- frontend/src/pages/forgot.tsx | 234 ++++++++--- frontend/src/pages/login.tsx | 579 ++++++++++++++------------ frontend/src/pages/register.tsx | 315 ++++++++++---- 4 files changed, 739 insertions(+), 401 deletions(-) diff --git a/frontend/src/components/FormField.tsx b/frontend/src/components/FormField.tsx index 6cb83e0..a48f9a4 100644 --- a/frontend/src/components/FormField.tsx +++ b/frontend/src/components/FormField.tsx @@ -34,7 +34,7 @@ const FormField = ({ icons = [], ...props }: Props) => { } const controlClassName = [ - `px-3 py-2 max-w-full border-gray-300 dark:border-dark-700 ${corners} w-full dark:placeholder-gray-400`, + `px-3 py-2 max-w-full w-full ${corners} border-gray-300 text-gray-900 placeholder:text-gray-400 dark:border-dark-700 dark:text-white dark:placeholder-gray-400`, `${focusRing}`, props.hasTextareaHeight ? 'h-24' : 'h-12', props.isBorderless ? 'border-0' : 'border', @@ -54,10 +54,13 @@ const FormField = ({ icons = [], ...props }: Props) => { )}
- {Children.map(props.children, (child: ReactElement, index) => ( + {Children.map(props.children, (child: ReactElement, index) => { + const childClassName = (child.props as { className?: string })?.className || '' + + return (
{cloneElement(child as ReactElement<{ className?: string }>, { - className: `${controlClassName} ${icons[index] ? 'pl-10' : ''}`, + className: `${controlClassName} ${childClassName} ${icons[index] ? 'pl-10' : ''}`.trim(), })} {icons[index] && ( { /> )}
- ))} + ) + })}
{props.help && (
{props.help}
diff --git a/frontend/src/pages/forgot.tsx b/frontend/src/pages/forgot.tsx index 39b0193..f3cd002 100644 --- a/frontend/src/pages/forgot.tsx +++ b/frontend/src/pages/forgot.tsx @@ -1,77 +1,217 @@ import React from 'react'; import type { ReactElement } from 'react'; -import { ToastContainer, toast } from 'react-toastify'; import Head from 'next/head'; +import Link from 'next/link'; +import { Field, Form, Formik } from 'formik'; +import { ToastContainer, toast } from 'react-toastify'; +import axios from 'axios'; + import BaseButton from '../components/BaseButton'; +import BaseButtons from '../components/BaseButtons'; +import BaseDivider from '../components/BaseDivider'; import CardBox from '../components/CardBox'; +import FormField from '../components/FormField'; import SectionFullScreen from '../components/SectionFullScreen'; import LayoutGuest from '../layouts/Guest'; -import { Field, Form, Formik } from 'formik'; -import FormField from '../components/FormField'; -import BaseDivider from '../components/BaseDivider'; -import BaseButtons from '../components/BaseButtons'; -import { useRouter } from 'next/router'; import { getPageTitle } from '../config'; -import axios from "axios"; +import { useAppSelector } from '../stores/hooks'; +import { useRouter } from 'next/router'; + +type ForgotValues = { + email: string; +}; + +const TITLE = 'Gracey Corporate Stay Portal'; +const RECOVERY_CHIPS = ['Booking access', 'Service access', 'Billing access']; + +const AUTH_INPUT_CLASS = '!bg-white !text-stone-900 placeholder:!text-stone-400 dark:!bg-white dark:!text-stone-900 dark:placeholder:!text-stone-400'; export default function Forgot() { - const [loading, setLoading] = React.useState(false) + const [loading, setLoading] = React.useState(false); const router = useRouter(); - const notify = (type, msg) => toast( msg, {type}); + const textColor = useAppSelector((state) => state.style.linkColor); + const notify = (type, msg) => toast(msg, { type, position: 'bottom-center' }); + + const handleSubmit = async (value: ForgotValues) => { + setLoading(true); - const handleSubmit = async (value) => { - setLoading(true) try { - const { data: response } = await axios.post('/auth/send-password-reset-email', value); - setLoading(false) - notify('success', 'Please check your email for verification link'); + await axios.post('/auth/send-password-reset-email', value); + notify('success', 'Please check your email for a password reset link.'); setTimeout(async () => { - await router.push('/login') - }, 3000) + await router.push('/login'); + }, 3000); } catch (error) { - setLoading(false) - console.log('error: ', error) - notify('error', 'Something was wrong. Try again') + console.error('Password reset email request failed:', error); + notify('error', 'Something was wrong. Try again.'); + } finally { + setLoading(false); } }; return ( <> - {getPageTitle('Login')} + {getPageTitle('Forgot password')} - - handleSubmit(values)} - > -
- - - +
+
+
+
- +
+
+
+

+ Credential recovery +

+
+

+ Restore access without losing your operating context. +

+

+ Request a reset link for the email you use in Gracey and return to the workspace with a fresh + password. +

+
+
+ {RECOVERY_CHIPS.map((chip) => ( + + {chip} + + ))} +
+
- - - - - - - +
+
+

+ Delivery path +

+

Email-based reset flow

+
+
+

+ Access posture +

+

Private and credential-safe

+
+
+

+ Return path +

+

Back to portal login

+
+
+ +
+
+ +

Recovery notes

+
+ +
+

+ Use the same email address you rely on for booking, service, or billing access inside the + workspace. +

+

+ Once the request is submitted, Gracey sends a time-sensitive link so you can create a new + password and re-enter the portal securely. +

+

+ If you no longer have access to the original inbox, contact an internal administrator before + attempting another reset. +

+
+
+
+ +
+ Recovery links are delivered only to recognized account emails and are intended for authorized + operators and customers. +
+
+
+ +
+
+ +
+
+

Password reset

+

Restore your password

+

+ Enter your email and we’ll send the next secure step to restore access to the Gracey portal. +

+
+ {RECOVERY_CHIPS.map((chip) => ( + + {chip} + + ))} +
+
+ + handleSubmit(values)} + > +
+ + + + + + + + + + +
+

Remembered your password?

+ + Return to Login + +
+ +
+
+
+ +
+

© 2026 {TITLE}. © All rights reserved

+
+ + Privacy Policy + + + Terms of Use + +
+
+
+
+
+
+ ); diff --git a/frontend/src/pages/login.tsx b/frontend/src/pages/login.tsx index fa2bf62..f978095 100644 --- a/frontend/src/pages/login.tsx +++ b/frontend/src/pages/login.tsx @@ -1,327 +1,378 @@ - - -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import type { ReactElement } from 'react'; import Head from 'next/head'; +import Link from 'next/link'; +import { Field, Form, Formik } from 'formik'; +import { ToastContainer, toast } from 'react-toastify'; +import { mdiEye, mdiEyeOff, mdiInformation } from '@mdi/js'; + import BaseButton from '../components/BaseButton'; +import BaseButtons from '../components/BaseButtons'; +import BaseDivider from '../components/BaseDivider'; +import BaseIcon from '../components/BaseIcon'; import CardBox from '../components/CardBox'; -import BaseIcon from "../components/BaseIcon"; -import { mdiInformation, mdiEye, mdiEyeOff } from '@mdi/js'; +import FormCheckRadio from '../components/FormCheckRadio'; +import FormField from '../components/FormField'; import SectionFullScreen from '../components/SectionFullScreen'; import LayoutGuest from '../layouts/Guest'; -import { Field, Form, Formik } from 'formik'; -import FormField from '../components/FormField'; -import FormCheckRadio from '../components/FormCheckRadio'; -import BaseDivider from '../components/BaseDivider'; -import BaseButtons from '../components/BaseButtons'; -import { useRouter } from 'next/router'; import { getPageTitle } from '../config'; import { findMe, loginUser, resetAction } from '../stores/authSlice'; import { useAppDispatch, useAppSelector } from '../stores/hooks'; -import Link from 'next/link'; -import {toast, ToastContainer} from "react-toastify"; -import { getPexelsImage, getPexelsVideo } from '../helpers/pexels' +import { useRouter } from 'next/router'; + +type LoginValues = { + email: string; + password: string; + remember: boolean; +}; + +type DemoAccount = { + email: string; + password: string; + role: string; + accent: string; + summary: string; +}; + +const DEMO_ACCOUNTS: DemoAccount[] = [ + { + email: 'super_admin@flatlogic.com', + password: '946cafba', + role: 'Super Admin', + accent: 'border-stone-300 bg-stone-900 text-white', + summary: 'Global workspace control across organizations and roles.', + }, + { + email: 'admin@flatlogic.com', + password: '946cafba', + role: 'Admin', + accent: 'border-amber-200 bg-amber-50 text-stone-900', + summary: 'Day-to-day operating visibility with organization context.', + }, + { + email: 'john@doe.com', + password: 'bc40952c93f4', + role: 'Concierge', + accent: 'border-stone-200 bg-white text-stone-900', + summary: 'Service coordination, reservation readiness, and updates.', + }, + { + email: 'client@hello.com', + password: 'bc40952c93f4', + role: 'Customer', + accent: 'border-amber-200 bg-white text-stone-900', + summary: 'Customer-side request access and stay visibility.', + }, +]; + +const ACCESS_CHIPS = ['Super Admin', 'Admin', 'Concierge', 'Customer']; + +const AUTH_INPUT_CLASS = '!bg-white !text-stone-900 placeholder:!text-stone-400 dark:!bg-white dark:!text-stone-900 dark:placeholder:!text-stone-400'; + +const DEFAULT_LOGIN_VALUES: LoginValues = { + email: DEMO_ACCOUNTS[0].email, + password: DEMO_ACCOUNTS[0].password, + remember: true, +}; export default function Login() { const router = useRouter(); const dispatch = useAppDispatch(); const textColor = useAppSelector((state) => state.style.linkColor); - const iconsColor = useAppSelector((state) => state.style.iconsColor); - const notify = (type, msg) => toast(msg, { type }); - const [ illustrationImage, setIllustrationImage ] = useState({ - src: undefined, - photographer: undefined, - photographer_url: undefined, - }) - const [ illustrationVideo, setIllustrationVideo ] = useState({video_files: []}) - const [contentType, setContentType] = useState('image'); - const [contentPosition, setContentPosition] = useState('right'); - const [showPassword, setShowPassword] = useState(false); - const { currentUser, isFetching, errorMessage, token, notify:notifyState } = useAppSelector( + const { currentUser, isFetching, errorMessage, token, notify: notifyState } = useAppSelector( (state) => state.auth, ); + const [showPassword, setShowPassword] = useState(false); + const [initialValues, setInitialValues] = useState(DEFAULT_LOGIN_VALUES); + + const notify = (type, msg) => toast(msg, { type }); + const title = 'Gracey Corporate Stay Portal'; + const registeredOrg = typeof router.query.registeredOrg === 'string' ? router.query.registeredOrg : ''; const registeredRole = typeof router.query.registeredRole === 'string' ? router.query.registeredRole : ''; const registeredTenants = typeof router.query.registeredTenants === 'string' ? router.query.registeredTenants.split(' | ').filter(Boolean) : []; - const [initialValues, setInitialValues] = React.useState({ email:'super_admin@flatlogic.com', - password: '946cafba', - remember: true }) - const title = 'Gracey Corporate Stay Portal' - - // Fetch Pexels image/video - useEffect( () => { - async function fetchData() { - const image = await getPexelsImage() - const video = await getPexelsVideo() - setIllustrationImage(image); - setIllustrationVideo(video); - } - fetchData(); - }, []); - // Fetch user data useEffect(() => { if (token) { dispatch(findMe()); } }, [token, dispatch]); - // Redirect to dashboard if user is logged in + useEffect(() => { if (currentUser?.id) { router.push('/command-center'); } }, [currentUser?.id, router]); - // Show error message if there is one + useEffect(() => { - if (errorMessage){ - notify('error', errorMessage) + if (errorMessage) { + notify('error', errorMessage); } + }, [errorMessage]); - }, [errorMessage]) - // Show notification if there is one useEffect(() => { - if (notifyState?.showNotification) { - notify('success', notifyState?.textNotification) - dispatch(resetAction()); - } - }, [notifyState?.showNotification]) + if (notifyState?.showNotification) { + notify('success', notifyState?.textNotification); + dispatch(resetAction()); + } + }, [notifyState?.showNotification, notifyState?.textNotification, dispatch]); - const togglePasswordVisibility = () => { - setShowPassword(!showPassword); - }; - - const handleSubmit = async (value) => { - const {remember, ...rest} = value + const handleSubmit = async (value: LoginValues) => { + const { remember, ...rest } = value; await dispatch(loginUser(rest)); }; - const setLogin = (target: HTMLElement) => { - setInitialValues(prev => ({ - ...prev, - email : target.innerText.trim(), - password: target.dataset.password ?? '', - })); + const chooseLogin = (email: string, password: string) => { + setInitialValues((prev) => ({ + ...prev, + email, + password, + })); }; - const imageBlock = (image) => ( -
-
- Photo - by {image?.photographer} on Pexels + const registeredMessage = useMemo(() => { + if (!registeredOrg) { + return null; + } + + return ( + +
+ +
+

+ Your {registeredRole || 'customer'} account was created for {registeredOrg}. +

+ {registeredTenants.length ? ( +
+

Linked tenants

+
+ {registeredTenants.map((tenantName) => ( + + {tenantName} + + ))} +
+
+ ) : ( +

No linked tenant is shown for that organization yet.

+ )} +

+ Admin and Super Admin accounts are provisioned internally, not through self-signup. +

-
- ) - - const videoBlock = (video) => { - if (video?.video_files?.length > 0) { - return ( -
- - -
) - } - }; +
+ + ); + }, [registeredOrg, registeredRole, registeredTenants]); return ( -
- - {getPageTitle('Login')} - + <> + + {getPageTitle('Login')} + - -
- {contentType === 'image' && contentPosition !== 'background' ? imageBlock(illustrationImage) : null} - {contentType === 'video' && contentPosition !== 'background' ? videoBlock(illustrationVideo) : null} -
- - - -

{title}

- -
-
- -

Use{' '} - setLogin(e.target)}>super_admin@flatlogic.com{' / '} - 946cafba{' / '} - to login as Super Admin

- -

Use{' '} - setLogin(e.target)}>admin@flatlogic.com{' / '} - 946cafba{' / '} - to login as Admin

-

Use setLogin(e.target)}>john@doe.com{' / '} - bc40952c93f4{' / '} - to login as Concierge

-

Use setLogin(e.target)}>client@hello.com{' / '} - bc40952c93f4{' / '} - to login as Customer

-
-
- -
+ +
+
+
+
+ +
+
+
+

+ Portal access +

+
+

+ {title} +

+

+ Sign in to the private workspace used for booking intake, guest coordination, and billing + follow-through. +

- - - {registeredOrg ? ( - -
- -
-

- Your {registeredRole || 'customer'} account was created for {registeredOrg}. -

- {registeredTenants.length ? ( -
-

Linked tenants

-
- {registeredTenants.map((tenantName) => ( - - {tenantName} - - ))} -
-
- ) : ( -

- No linked tenant is shown for that organization yet. -

- )} -

- Admin and Super Admin accounts are provisioned internally, not through self-signup. -

+
+ {ACCESS_CHIPS.map((chip) => ( + + {chip} + + ))} +
+
+ +
+
+

+ Operator view +

+

Multi-tenant workspace

+
+
+

+ Service posture +

+

Calm, selective, precise

+
+
+

+ Execution model +

+

Request to reservation to billing

+
+
+ +
+
+ +

+ Demo account access +

+
+
+ {DEMO_ACCOUNTS.map((account) => { + const isSelected = + initialValues.email === account.email && initialValues.password === account.password; + + return ( + + ); + })} +
+
+
+ +
+ The public experience stays polished and restrained. Operational depth appears only where it + belongs: inside the authenticated workspace. +
+
+
+ +
+
+ {registeredMessage} + + +
+
+

Member sign in

+

Welcome back

+

+ Enter your credentials to open the Gracey workspace. +

+
+ {ACCESS_CHIPS.map((chip) => ( + + {chip} + + ))} +
+
+ + +
+ + + + +
+ + + +
- - ) : null} - - handleSubmit(values)} - > - - - - +
+ + + -
- - - -
- -
-
+ + Forgot password? + +
-
- - - + - - Forgot password? - -
+ + + - - - - - -
-

- Don’t have an account yet?{' '} - - New Account - -

- +

+ Don’t have an account yet?{' '} + + New Account + +

+
+
+ +
+

© 2026 {title}. © All rights reserved

+
+ + Privacy Policy + + + Terms of Use + +
+
+
- -
-

© 2026 {title}. © All rights reserved

- - Privacy Policy -
- -
+ + + + ); } diff --git a/frontend/src/pages/register.tsx b/frontend/src/pages/register.tsx index 68ba80f..a955a16 100644 --- a/frontend/src/pages/register.tsx +++ b/frontend/src/pages/register.tsx @@ -1,20 +1,22 @@ import React from 'react'; import type { ReactElement } from 'react'; -import { ToastContainer, toast } from 'react-toastify'; import Head from 'next/head'; -import BaseButton from '../components/BaseButton'; -import CardBox from '../components/CardBox'; -import SectionFullScreen from '../components/SectionFullScreen'; -import LayoutGuest from '../layouts/Guest'; +import Link from 'next/link'; import { Field, Form, Formik } from 'formik'; -import FormField from '../components/FormField'; -import BaseDivider from '../components/BaseDivider'; -import BaseButtons from '../components/BaseButtons'; -import { useRouter } from 'next/router'; -import { getPageTitle } from '../config'; +import { ToastContainer, toast } from 'react-toastify'; import Select from 'react-select'; import axios from 'axios'; -import ConnectedEntityNotice from '../components/ConnectedEntityNotice'; + +import BaseButton from '../components/BaseButton'; +import BaseButtons from '../components/BaseButtons'; +import BaseDivider from '../components/BaseDivider'; +import CardBox from '../components/CardBox'; +import FormField from '../components/FormField'; +import SectionFullScreen from '../components/SectionFullScreen'; +import LayoutGuest from '../layouts/Guest'; +import { getPageTitle } from '../config'; +import { useAppSelector } from '../stores/hooks'; +import { useRouter } from 'next/router'; type PublicTenantOption = { id: string; @@ -40,11 +42,23 @@ type OrganizationSelectOption = { organization: PublicOrganizationOption; }; +type RegisterValues = { + email: string; + password: string; + confirm: string; +}; + +const TITLE = 'Gracey Corporate Stay Portal'; +const REGISTER_CHIPS = ['Customer self-signup', 'Organization-linked access', 'Internal admin provisioning']; + +const AUTH_INPUT_CLASS = '!bg-white !text-stone-900 placeholder:!text-stone-400 dark:!bg-white dark:!text-stone-900 dark:placeholder:!text-stone-400'; + export default function Register() { const [loading, setLoading] = React.useState(false); const [organizations, setOrganizations] = React.useState([]); const [selectedOrganization, setSelectedOrganization] = React.useState(null); const router = useRouter(); + const textColor = useAppSelector((state) => state.style.linkColor); const notify = (type, msg) => toast(msg, { type, position: 'bottom-center' }); React.useEffect(() => { @@ -69,7 +83,7 @@ export default function Register() { const selectedOrganizationRecord = selectedOrganization?.organization || null; const selectedTenants = selectedOrganizationRecord?.linkedTenants || []; - const handleSubmit = async (value) => { + const handleSubmit = async (value: RegisterValues) => { if (!selectedOrganization) { notify('error', 'Please select your organization before creating the account.'); return; @@ -114,87 +128,216 @@ export default function Register() { {getPageTitle('Register')} - - - handleSubmit(values)} - > -
- + +
+
+
+
- - classNames={{ - control: () => 'px-1 mb-4 py-2', - }} - value={selectedOrganization} - onChange={(option) => setSelectedOrganization(option)} - options={options} - placeholder="Select organization..." - /> - - {selectedOrganizationRecord ? ( - -

- Self-signup creates a Customer account inside{' '} - {selectedOrganizationRecord.name}. -

-
-

- Linked tenant preview -

- {selectedTenants.length ? ( -
- {selectedTenants.map((tenant) => ( - - {tenant.name || 'Unnamed tenant'} - - ))} -
- ) : ( -

- No linked tenant is visible for this organization yet. -

- )} -
-

- Admin and Super Admin accounts are created internally by an existing privileged user. +

+
+
+

+ Customer enrollment +

+
+

+ Create portal access with the right organization attached. +

+

+ Register a customer account for Gracey and connect it to the correct organization before the + first stay request is submitted.

- } - /> - ) : null} +
+ {REGISTER_CHIPS.map((chip) => ( + + {chip} + + ))} +
+
- - - - - - - - - +
+
+

+ Audience +

+

Customer-side account setup

+
+
+

+ Organization scope +

+

Attach access to the right company

+
+
+

+ Admin note +

+

Admin roles stay internally provisioned

+
+
- +
+
+ +

Enrollment notes

+
- - - - - - - +
+

+ Select the organization that should own the account. This keeps customer access tied to the + right operating context from the start. +

+

+ Linked tenants, if available, become visible after account creation so the login experience + stays consistent with the workspace structure. +

+

+ Need admin or concierge access instead? Those roles should be created internally rather than + through public signup. +

+
+
+
+ +
+ Gracey keeps public account creation narrow on purpose: clear audience, clear tenant context, and a + quieter path into the authenticated workspace. +
+
+
+ +
+
+ +
+
+

Customer access

+

Create your account

+

+ Set your portal credentials and attach them to the right organization before your first stay + request begins. +

+
+ {REGISTER_CHIPS.map((chip) => ( + + {chip} + + ))} +
+
+ + {selectedOrganizationRecord ? ( +
+

+ Account context +

+

+ This signup creates a Customer account inside{' '} + {selectedOrganizationRecord.name}. +

+
+ ) : null} + + handleSubmit(values)} + > +
+
+ + + classNames={{ + control: ({ isFocused }) => + `min-h-[42px] rounded-lg border bg-white px-2 py-1 text-sm shadow-none ${ + isFocused + ? 'border-stone-900 ring-1 ring-stone-900' + : 'border-stone-300 hover:border-stone-400' + }`, + menu: () => 'overflow-hidden rounded-xl border border-stone-200 bg-white shadow-xl', + menuList: () => 'p-2', + option: ({ isFocused, isSelected }) => + `cursor-pointer rounded-lg px-3 py-2 text-sm ${ + isSelected + ? 'bg-stone-900 text-white' + : isFocused + ? 'bg-amber-50 text-stone-900' + : 'text-stone-700' + }`, + placeholder: () => 'text-stone-400', + singleValue: () => 'text-stone-900', + indicatorSeparator: () => 'hidden', + dropdownIndicator: () => 'text-stone-500', + }} + value={selectedOrganization} + onChange={(option) => setSelectedOrganization(option)} + options={options} + placeholder='Select organization...' + /> +
+ + + + + + + + + + + + + + + + + +
+

Already have credentials?

+ + Return to Login + +
+ +
+
+
+ +
+

© 2026 {TITLE}. © All rights reserved

+
+ + Privacy Policy + + + Terms of Use + +
+
+
+
+
+
+ );