Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"version": "2.2.0-rc.0",
"version": "2.2.0-rc.1",
"license": "MIT",
"main": "dist/index.cjs",
"module": "dist/index.mjs",
Expand Down Expand Up @@ -91,7 +91,7 @@
"typescript": "^5.7.2"
},
"dependencies": {
"@authorizerdev/authorizer-js": "^3.3.0-rc.1",
"@authorizerdev/authorizer-js": "^3.3.0-rc.2",
"@storybook/preset-scss": "^1.0.3",
"validator": "^13.11.0"
}
Expand Down
62 changes: 38 additions & 24 deletions src/components/AuthorizerBasicAuthLogin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { ButtonAppearance, MessageType, Views } from '../constants';
import { useAuthorizer } from '../contexts/AuthorizerContext';
import { StyledButton, StyledFooter, StyledLink } from '../styledComponents';
import { Message } from './Message';
import { PasswordInput } from './PasswordInput';
import { AuthorizerVerifyOtp } from './AuthorizerVerifyOtp';
import { OtpDataType } from '../types';
import { AuthorizerMFASetup } from './AuthorizerMFASetup';
Expand All @@ -26,6 +27,7 @@ type MfaOfferData = {
email: string;
phone_number: string;
totpEnrollment: TotpEnrollment | null;
passkey: boolean;
emailOtp: boolean;
smsOtp: boolean;
state?: string;
Expand All @@ -36,6 +38,7 @@ const initMfaOfferData: MfaOfferData = {
email: '',
phone_number: '',
totpEnrollment: null,
passkey: false,
emailOtp: false,
smsOtp: false,
};
Expand All @@ -45,12 +48,20 @@ interface InputDataType {
password: string | null;
}

export type BasicAuthLoginStep = 'form' | 'mfa-setup' | 'otp-verify' | 'locked';

export const AuthorizerBasicAuthLogin: FC<{
setView?: (v: Views) => void;
onLogin?: (data: AuthToken | void) => void;
urlProps?: Record<string, any>;
roles?: string[];
}> = ({ setView, onLogin, urlProps, roles }) => {
// Fired whenever this component switches between its own screens. See
// AuthorizerSignup's identical prop for why hosts need this: a successful
// login that still needs MFA setup/verification takes over the whole
// login surface - other login options (social buttons, passkey button)
// don't belong stacked on top of those screens.
onStepChange?: (step: BasicAuthLoginStep) => void;
}> = ({ setView, onLogin, urlProps, roles, onStepChange }) => {
const [error, setError] = useState(``);
const [loading, setLoading] = useState(false);
const [otpData, setOtpData] = useState<OtpDataType>({ ...initOtpData });
Expand All @@ -68,6 +79,19 @@ export const AuthorizerBasicAuthLogin: FC<{
});
const { setAuthData, config, authorizerRef } = useAuthorizer();

useEffect(() => {
if (!onStepChange) return;
if (locked) {
onStepChange('locked');
} else if (mfaOfferData.is_screen_visible) {
onStepChange('mfa-setup');
} else if (otpData.is_screen_visible) {
onStepChange('otp-verify');
} else {
onStepChange('form');
}
}, [locked, mfaOfferData.is_screen_visible, otpData.is_screen_visible]);

const onInputChange = async (field: string, value: string) => {
setFormData({ ...formData, [field]: value });
};
Expand Down Expand Up @@ -124,6 +148,7 @@ export const AuthorizerBasicAuthLogin: FC<{
email: data.email || ``,
phone_number: data.phone_number || ``,
totpEnrollment: step.totpEnrollment,
passkey: step.passkey,
emailOtp: step.emailOtp,
smsOtp: step.smsOtp,
state: urlProps?.state,
Expand Down Expand Up @@ -204,12 +229,13 @@ export const AuthorizerBasicAuthLogin: FC<{
<AuthorizerMFASetup
availableMfaMethods={{
totp: !!mfaOfferData.totpEnrollment || config.is_totp_mfa_enabled,
passkey: false,
passkey: mfaOfferData.passkey,
emailOtp: mfaOfferData.emailOtp,
smsOtp: mfaOfferData.smsOtp,
}}
totpEnrollment={mfaOfferData.totpEnrollment || undefined}
heading="Set up multi-factor authentication"
onBack={() => setMfaOfferData({ ...initMfaOfferData })}
loginContext={{
email: mfaOfferData.email,
phone_number: mfaOfferData.phone_number,
Expand Down Expand Up @@ -241,6 +267,7 @@ export const AuthorizerBasicAuthLogin: FC<{
is_totp: otpData.is_totp || false,
offerWebauthnVerify: otpData.offer_webauthn_verify || false,
hasCodeFactor: otpData.has_code_factor || false,
onBack: () => setOtpData({ ...initOtpData }),
}}
urlProps={urlProps}
/>
Expand Down Expand Up @@ -282,28 +309,15 @@ export const AuthorizerBasicAuthLogin: FC<{
</div>
)}
</div>
<div className="styled-form-group">
<label
className="form-input-label"
htmlFor="authorizer-login-password"
>
<span>* </span>Password
</label>
<input
name="password"
id="authorizer-login-password"
className={`form-input-field ${
errorData.password ? 'input-error-content' : ''
}`}
placeholder="********"
type="password"
value={formData.password || ''}
onChange={e => onInputChange('password', e.target.value)}
/>
{errorData.password && (
<div className="form-input-error">{errorData.password}</div>
)}
</div>
<PasswordInput
id="authorizer-login-password"
name="password"
label="Password"
autoComplete="current-password"
value={formData.password || ''}
onChange={(value) => onInputChange('password', value)}
error={errorData.password}
/>
<br />
<StyledButton
type="submit"
Expand Down
72 changes: 52 additions & 20 deletions src/components/AuthorizerMFASetup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,23 +11,13 @@ import {
} from '../icons/mfa';
import { StyledButton } from '../styledComponents';
import { Message } from './Message';
import { BackLink } from './BackLink';
import { AuthorizerTOTPScanner } from './AuthorizerTOTPScanner';
import { AuthorizerPasskeyRegister } from './AuthorizerPasskeyRegister';
import { AuthorizerVerifyOtp } from './AuthorizerVerifyOtp';
import { useAuthorizer } from '../contexts/AuthorizerContext';
import { TotpEnrollment } from '../utils/mfaTriage';

const BackLink: FC<{ onClick: () => void }> = ({ onClick }) => (
<button
type="button"
className="mfa-icon-button"
onClick={onClick}
style={{ border: 'none', background: 'none', padding: '4px 0' }}
>
&larr; All methods
</button>
);

export type MfaMethod = 'totp' | 'passkey' | 'email_otp' | 'sms_otp';

// Which MFA methods the server offers. This shape is designed to map 1:1
Expand Down Expand Up @@ -76,12 +66,24 @@ export const AuthorizerMFASetup: FC<{
state?: string;
onComplete: (response: AuthTokenLike) => void;
};
// When present, a "Back" link is shown on the top-level method list so the
// host can let the user leave MFA setup entirely (distinct from BackLink's
// existing "All methods" links, which only return from a method's
// sub-flow to this same list).
onBack?: () => void;
// Whether the signed-in user already has at least one passkey registered
// (the host knows this via webauthnCredentials - there's no per-user
// enrolment signal for TOTP/email-OTP/SMS-OTP available to the client, so
// only passkey can be accurately highlighted as already set up).
passkeyRegistered?: boolean;
}> = ({
availableMfaMethods,
totpEnrollment,
onSetupMethod,
heading = 'Add a second step to sign in',
loginContext,
onBack,
passkeyRegistered,
}) => {
const [selected, setSelected] = useState<MfaMethod | null>(null);
const [notice, setNotice] = useState('');
Expand Down Expand Up @@ -110,6 +112,7 @@ export const AuthorizerMFASetup: FC<{
description: string;
disabled?: boolean;
disabledReason?: string;
enabled?: boolean;
}[] = [
{
key: 'totp',
Expand All @@ -120,12 +123,13 @@ export const AuthorizerMFASetup: FC<{
},
{
key: 'passkey',
available: !!availableMfaMethods.passkey && !loginContext,
available: !!availableMfaMethods.passkey,
icon: <IconPasskey />,
title: 'Passkey',
description: 'Sign in with your fingerprint, face, or device PIN.',
disabled: !passkeySupported,
disabledReason: 'Not supported on this browser or device.',
enabled: !!passkeyRegistered,
},
{
key: 'email_otp',
Expand Down Expand Up @@ -264,12 +268,11 @@ export const AuthorizerMFASetup: FC<{
if (selected === 'totp' && effectiveTotpEnrollment) {
return (
<>
<BackLink onClick={backToList} />
<BackLink onClick={backToList} label="All methods" />
<AuthorizerTOTPScanner
{...effectiveTotpEnrollment}
email={loginContext?.email}
phone_number={loginContext?.phone_number}
setView={backToList}
onLogin={(data) => {
if (loginContext && data && (data as AuthTokenLike).access_token) {
loginContext.onComplete(data as AuthTokenLike);
Expand All @@ -285,7 +288,7 @@ export const AuthorizerMFASetup: FC<{
if (otpMethodPending) {
return (
<>
<BackLink onClick={() => setOtpMethodPending(null)} />
<BackLink onClick={() => setOtpMethodPending(null)} label="All methods" />
<AuthorizerVerifyOtp
email={loginContext?.email}
phone_number={loginContext?.phone_number}
Expand All @@ -306,15 +309,36 @@ export const AuthorizerMFASetup: FC<{
if (selected === 'passkey') {
return (
<>
<BackLink onClick={backToList} />
<BackLink onClick={backToList} label="All methods" />
<p style={{ margin: '10px 0px', fontWeight: 'bold' }}>Add a passkey</p>
<AuthorizerPasskeyRegister onSuccess={backToList} showCredentials />
<AuthorizerPasskeyRegister
// showCredentials calls webauthn_credentials, which requires a
// bearer token - never available yet during a login-time offer.
showCredentials={!loginContext}
mfaSetup={
loginContext
? {
email: loginContext.email,
phoneNumber: loginContext.phone_number,
state: loginContext.state,
}
: undefined
}
onSuccess={(data) => {
if (loginContext && data && (data as AuthTokenLike).access_token) {
loginContext.onComplete(data as AuthTokenLike);
return;
}
backToList();
}}
/>
</>
);
}

return (
<>
{onBack && <BackLink onClick={onBack} />}
<p style={{ margin: '10px 0px', fontWeight: 'bold' }}>{heading}</p>
{notice && (
<Message
Expand All @@ -339,10 +363,18 @@ export const AuthorizerMFASetup: FC<{
) : (
<ul className="mfa-list" aria-label="Available multi-factor methods">
{visibleMethods.map((m) => (
<li key={m.key} className="mfa-method">
<li
key={m.key}
className={`mfa-method${m.enabled ? ' mfa-method-enabled' : ''}`}
>
<span className="mfa-method-icon">{m.icon}</span>
<div className="mfa-method-body">
<p className="mfa-method-title">{m.title}</p>
<p className="mfa-method-title">
{m.title}
{m.enabled && (
<span className="mfa-method-badge">Enabled</span>
)}
</p>
<p className="mfa-method-desc">
{m.disabled && m.disabledReason
? m.disabledReason
Expand All @@ -357,7 +389,7 @@ export const AuthorizerMFASetup: FC<{
onClick={() => handleSetup(m.key)}
style={{ width: 'auto' }}
>
Set up
{m.enabled ? 'Manage' : 'Set up'}
</StyledButton>
</div>
</li>
Expand Down
Loading
Loading