Commit 6b577681 by krds-arun

feat(login): designed new 2fa and forgot password flow

parent 068388c8
...@@ -18,7 +18,32 @@ export const Open: Story = { ...@@ -18,7 +18,32 @@ export const Open: Story = {
open: true, open: true,
onClose: () => {}, onClose: () => {},
onMicrosoftSignIn: () => {}, onMicrosoftSignIn: () => {},
onContractorSignIn: () => {},
onVerifyTwoFactor: () => {},
onSendResetLink: () => {},
onContactSupport: () => {}, onContactSupport: () => {},
}, },
}; };
export const ContractorLogin: Story = {
args: {
...Open.args,
initialAccountType: "contractor",
initialView: "login",
},
};
export const TwoFactorVerification: Story = {
args: {
...Open.args,
initialView: "twoFactor",
},
};
export const ForgotPassword: Story = {
args: {
...Open.args,
initialView: "forgotPassword",
},
};
...@@ -5,12 +5,24 @@ import { ModalShell } from "@/components/widgets/ModalShell/ModalShell"; ...@@ -5,12 +5,24 @@ import { ModalShell } from "@/components/widgets/ModalShell/ModalShell";
import "./login-modal.css"; import "./login-modal.css";
export type LoginAccountType = "employee" | "contractor"; export type LoginAccountType = "employee" | "contractor";
export type LoginModalView = "login" | "twoFactor" | "forgotPassword";
type ContractorCredentials = {
username: string;
password: string;
rememberMe: boolean;
};
export type LoginModalProps = { export type LoginModalProps = {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
initialView?: LoginModalView;
initialAccountType?: LoginAccountType;
onContactSupport?: () => void; onContactSupport?: () => void;
onMicrosoftSignIn?: (accountType: LoginAccountType) => void; onMicrosoftSignIn?: (accountType: LoginAccountType) => void;
onContractorSignIn?: (credentials: ContractorCredentials) => void;
onVerifyTwoFactor?: (code: string) => void;
onSendResetLink?: (email: string) => void;
className?: string; className?: string;
}; };
...@@ -48,14 +60,75 @@ function MicrosoftGlyph({ className }: { className?: string }) { ...@@ -48,14 +60,75 @@ function MicrosoftGlyph({ className }: { className?: string }) {
); );
} }
function LockGlyph({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" aria-hidden>
<path
d="M7.5 10V8.25a4.5 4.5 0 1 1 9 0V10"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
<rect
x="5.5"
y="10"
width="13"
height="9.5"
rx="2.4"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
/>
</svg>
);
}
function MailGlyph({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" aria-hidden>
<rect
x="3"
y="5.5"
width="18"
height="13"
rx="2.2"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
/>
<path
d="m4.8 7.2 7.2 6 7.2-6"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export function LoginModal({ export function LoginModal({
open, open,
onClose, onClose,
initialView = "login",
initialAccountType = "employee",
onContactSupport, onContactSupport,
onMicrosoftSignIn, onMicrosoftSignIn,
onContractorSignIn,
onVerifyTwoFactor,
onSendResetLink,
className = "", className = "",
}: LoginModalProps) { }: LoginModalProps) {
const [accountType, setAccountType] = useState<LoginAccountType>("employee"); const [accountType, setAccountType] = useState<LoginAccountType>(initialAccountType);
const [view, setView] = useState<LoginModalView>(initialView);
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [rememberMe, setRememberMe] = useState(false);
const [email, setEmail] = useState("");
const [usernameError, setUsernameError] = useState("");
const [otpDigits, setOtpDigits] = useState<string[]>(["", "", "", "", "", ""]);
const helperCopy = useMemo(() => { const helperCopy = useMemo(() => {
if (accountType === "employee") { if (accountType === "employee") {
...@@ -70,6 +143,35 @@ export function LoginModal({ ...@@ -70,6 +143,35 @@ export function LoginModal({
}; };
}, [accountType]); }, [accountType]);
const otpCode = otpDigits.join("");
const isEmployee = accountType === "employee";
const handleContractorSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!username.trim()) {
setUsernameError("Please enter your username.");
return;
}
setUsernameError("");
onContractorSignIn?.({ username: username.trim(), password, rememberMe });
};
const handleOtpInput = (index: number, value: string) => {
const nextValue = value.replace(/\D/g, "").slice(0, 1);
const updated = [...otpDigits];
updated[index] = nextValue;
setOtpDigits(updated);
if (nextValue && index < 5) {
const nextElement = document.getElementById(`maf-otp-${index + 1}`) as HTMLInputElement | null;
nextElement?.focus();
}
};
const handleBackToLogin = () => setView("login");
return ( return (
<ModalShell <ModalShell
open={open} open={open}
...@@ -79,6 +181,14 @@ export function LoginModal({ ...@@ -79,6 +181,14 @@ export function LoginModal({
title="Welcome back" title="Welcome back"
className={`maf-login-modal ${className}`.trim()} className={`maf-login-modal ${className}`.trim()}
> >
{view !== "login" ? (
<button type="button" className="maf-login-modal__backLink" onClick={handleBackToLogin}>
← Back to sign in
</button>
) : null}
{view === "login" ? (
<>
<p className="maf-login-modal__subtitle">Choose your account type to continue</p> <p className="maf-login-modal__subtitle">Choose your account type to continue</p>
<div className="maf-login-modal__tabs" role="tablist" aria-label="Account type"> <div className="maf-login-modal__tabs" role="tablist" aria-label="Account type">
...@@ -102,6 +212,8 @@ export function LoginModal({ ...@@ -102,6 +212,8 @@ export function LoginModal({
</button> </button>
</div> </div>
{isEmployee ? (
<>
<section className="maf-login-modal__info" aria-label="Sign in information"> <section className="maf-login-modal__info" aria-label="Sign in information">
<span className="maf-login-modal__infoIcon" aria-hidden> <span className="maf-login-modal__infoIcon" aria-hidden>
<InfoGlyph className="maf-login-modal__infoSvg" /> <InfoGlyph className="maf-login-modal__infoSvg" />
...@@ -126,6 +238,125 @@ export function LoginModal({ ...@@ -126,6 +238,125 @@ export function LoginModal({
</span> </span>
<span>Secure redirect via Microsoft — your credentials never touch our servers</span> <span>Secure redirect via Microsoft — your credentials never touch our servers</span>
</div> </div>
</>
) : (
<form className="maf-login-modal__form" onSubmit={handleContractorSubmit}>
<label className="maf-login-modal__label" htmlFor="maf-login-username">
Company username
</label>
<input
id="maf-login-username"
className={`maf-login-modal__input ${usernameError ? "is-error" : ""}`.trim()}
type="text"
value={username}
onChange={(event) => setUsername(event.target.value)}
autoComplete="username"
placeholder="Enter your username"
/>
{usernameError ? <p className="maf-login-modal__error">{usernameError}</p> : null}
<label className="maf-login-modal__label" htmlFor="maf-login-password">
Password
</label>
<input
id="maf-login-password"
className="maf-login-modal__input"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete="current-password"
placeholder="Your password"
/>
<div className="maf-login-modal__row">
<label className="maf-login-modal__remember">
<input
type="checkbox"
checked={rememberMe}
onChange={(event) => setRememberMe(event.target.checked)}
/>
<span>Remember me</span>
</label>
<button
type="button"
className="maf-login-modal__linkButton"
onClick={() => setView("forgotPassword")}
>
Forgot password?
</button>
</div>
<button type="submit" className="maf-login-modal__primaryButton">
Log in
</button>
</form>
)}
</>
) : null}
{view === "twoFactor" ? (
<section>
<span className="maf-login-modal__pill">
<LockGlyph className="maf-login-modal__pillIcon" /> Two-factor authentication
</span>
<h3 className="maf-login-modal__viewTitle font-serif">Verify it&apos;s you</h3>
<p className="maf-login-modal__subtitle">
Enter the 6-digit code from your authenticator app. If you don&apos;t have the app, we can
send a code to your email.
</p>
<div className="maf-login-modal__otp" role="group" aria-label="One-time password">
{otpDigits.map((digit, index) => (
<input
key={index}
id={`maf-otp-${index}`}
className="maf-login-modal__otpInput"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
value={digit}
onChange={(event) => handleOtpInput(index, event.target.value)}
aria-label={`Digit ${index + 1}`}
/>
))}
</div>
<button type="button" className="maf-login-modal__primaryButton" onClick={() => onVerifyTwoFactor?.(otpCode)}>
Verify &amp; continue
</button>
<button type="button" className="maf-login-modal__linkCta">
Send code to my email instead
</button>
</section>
) : null}
{view === "forgotPassword" ? (
<section>
<div className="maf-login-modal__iconBadge" aria-hidden>
<MailGlyph className="maf-login-modal__iconBadgeSvg" />
</div>
<h3 className="maf-login-modal__viewTitle font-serif">Reset your password</h3>
<p className="maf-login-modal__subtitle">
Enter the email address linked to your account. We&apos;ll send you a secure link to create a
new password.
</p>
<label className="maf-login-modal__label" htmlFor="maf-reset-email">
Email address
</label>
<input
id="maf-reset-email"
className="maf-login-modal__input"
type="email"
autoComplete="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
placeholder="name@example.com"
/>
<button type="button" className="maf-login-modal__primaryButton" onClick={() => onSendResetLink?.(email.trim())}>
Send reset link
</button>
</section>
) : null}
<div className="maf-login-modal__footer"> <div className="maf-login-modal__footer">
<span className="maf-login-modal__footerMuted">Need help accessing your account?</span> <span className="maf-login-modal__footerMuted">Need help accessing your account?</span>
......
...@@ -2,6 +2,16 @@ ...@@ -2,6 +2,16 @@
margin-top: 0.35rem; margin-top: 0.35rem;
} }
.maf-login-modal__backLink {
margin: 0 0 0.7rem;
border: 0;
background: transparent;
color: color-mix(in srgb, var(--foreground, #f4f0e8) 56%, transparent);
font-size: 0.98rem;
cursor: pointer;
padding: 0;
}
.maf-login-modal__subtitle { .maf-login-modal__subtitle {
margin: 0.55rem 0 1.1rem; margin: 0.55rem 0 1.1rem;
color: color-mix(in srgb, var(--foreground, #f4f0e8) 62%, transparent); color: color-mix(in srgb, var(--foreground, #f4f0e8) 62%, transparent);
...@@ -169,3 +179,164 @@ ...@@ -169,3 +179,164 @@
outline-offset: 3px; outline-offset: 3px;
} }
.maf-login-modal__form {
margin-top: 1.1rem;
}
.maf-login-modal__label {
display: block;
margin: 1rem 0 0.45rem;
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.78rem;
font-weight: 700;
color: color-mix(in srgb, var(--foreground, #f4f0e8) 70%, transparent);
}
.maf-login-modal__input {
width: 100%;
border-radius: 12px;
border: 1px solid color-mix(in srgb, var(--accent, #c4a574) 22%, transparent);
background: rgba(0, 0, 0, 0.28);
color: rgba(255, 255, 255, 0.92);
padding: 0.85rem 0.9rem;
font-size: 0.96rem;
}
.maf-login-modal__input::placeholder {
color: color-mix(in srgb, var(--foreground, #f4f0e8) 48%, transparent);
}
.maf-login-modal__input:focus-visible {
outline: 2px solid var(--accent, #c4a574);
outline-offset: 2px;
}
.maf-login-modal__input.is-error {
border-color: rgba(255, 88, 110, 0.85);
}
.maf-login-modal__error {
margin: 0.4rem 0 0;
color: rgba(255, 105, 126, 0.95);
font-size: 0.82rem;
}
.maf-login-modal__row {
margin-top: 0.9rem;
display: flex;
align-items: center;
justify-content: space-between;
}
.maf-login-modal__remember {
display: inline-flex;
align-items: center;
gap: 0.45rem;
color: color-mix(in srgb, var(--foreground, #f4f0e8) 65%, transparent);
font-size: 0.95rem;
}
.maf-login-modal__linkButton,
.maf-login-modal__linkCta {
border: 0;
background: transparent;
color: color-mix(in srgb, var(--accent, #c4a574) 92%, transparent);
text-decoration: underline;
text-underline-offset: 0.2em;
cursor: pointer;
}
.maf-login-modal__primaryButton {
margin-top: 1.1rem;
width: 100%;
border: 0;
border-radius: 14px;
padding: 0.9rem 1rem;
font-size: 1.02rem;
font-weight: 800;
letter-spacing: 0.01em;
color: #fff;
background: linear-gradient(145deg, #9f103f 0%, #da2f6c 100%);
box-shadow:
0 16px 42px rgba(194, 24, 84, 0.34),
inset 0 1px 0 rgba(255, 255, 255, 0.2);
cursor: pointer;
}
.maf-login-modal__primaryButton:hover {
filter: brightness(1.06);
}
.maf-login-modal__pill {
display: inline-flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.2rem;
padding: 0.42rem 0.9rem;
border-radius: 999px;
border: 1px solid color-mix(in srgb, var(--accent, #c4a574) 25%, transparent);
background: color-mix(in srgb, var(--accent, #c4a574) 10%, transparent);
color: color-mix(in srgb, var(--accent, #c4a574) 90%, transparent);
font-size: 0.94rem;
}
.maf-login-modal__pillIcon {
width: 16px;
height: 16px;
}
.maf-login-modal__viewTitle {
margin: 1rem 0 0.35rem;
font-size: 2rem;
line-height: 1.08;
color: color-mix(in srgb, var(--foreground, #f4f0e8) 92%, transparent);
font-weight: 520;
}
.maf-login-modal__otp {
margin-top: 1.1rem;
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 0.5rem;
}
.maf-login-modal__otpInput {
width: 100%;
border-radius: 10px;
border: 1px solid color-mix(in srgb, var(--accent, #c4a574) 25%, transparent);
background: rgba(0, 0, 0, 0.25);
color: rgba(255, 255, 255, 0.92);
text-align: center;
font-size: 1.1rem;
height: 3.15rem;
}
.maf-login-modal__otpInput:focus-visible {
outline: 2px solid var(--accent, #c4a574);
outline-offset: 2px;
}
.maf-login-modal__linkCta {
width: 100%;
text-align: center;
margin-top: 0.95rem;
}
.maf-login-modal__iconBadge {
width: 68px;
height: 68px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid color-mix(in srgb, var(--accent, #c4a574) 28%, transparent);
background: radial-gradient(circle at 50% 35%, rgba(212, 173, 99, 0.25), rgba(0, 0, 0, 0.18));
}
.maf-login-modal__iconBadgeSvg {
width: 26px;
height: 26px;
color: color-mix(in srgb, var(--accent, #c4a574) 90%, transparent);
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment