frontend signup
This commit is contained in:
+89
-8
@@ -40,7 +40,9 @@ import usePasskeys from './settings/usePasskeys';
|
|||||||
import {
|
import {
|
||||||
isWebAuthnAvailable,
|
isWebAuthnAvailable,
|
||||||
preparePublicKeyRequestOptions,
|
preparePublicKeyRequestOptions,
|
||||||
|
preparePublicKeyCreationOptions,
|
||||||
serializeAuthenticationCredential,
|
serializeAuthenticationCredential,
|
||||||
|
serializeRegistrationCredential,
|
||||||
} from './utils/webauthn';
|
} from './utils/webauthn';
|
||||||
|
|
||||||
const ASSET_PRESIGN_TTL_MS = 240 * 1000; // backend issues 5 min tokens; refresh slightly early
|
const ASSET_PRESIGN_TTL_MS = 240 * 1000; // backend issues 5 min tokens; refresh slightly early
|
||||||
@@ -5699,6 +5701,8 @@ const LoginRoute = () => {
|
|||||||
const [selectingTenantId, setSelectingTenantId] = useState(null);
|
const [selectingTenantId, setSelectingTenantId] = useState(null);
|
||||||
const passkeySupported = isWebAuthnAvailable();
|
const passkeySupported = isWebAuthnAvailable();
|
||||||
const [passkeyLoading, setPasskeyLoading] = useState(false);
|
const [passkeyLoading, setPasskeyLoading] = useState(false);
|
||||||
|
const signupSupported = passkeySupported;
|
||||||
|
const [signupLoading, setSignupLoading] = useState(false);
|
||||||
|
|
||||||
const setStatusMessage = useCallback((message, variant = 'info') => {
|
const setStatusMessage = useCallback((message, variant = 'info') => {
|
||||||
setStatus(message ? { message, variant } : null);
|
setStatus(message ? { message, variant } : null);
|
||||||
@@ -5775,13 +5779,8 @@ const LoginRoute = () => {
|
|||||||
appDispatch({ type: 'LOGIN_REQUEST' });
|
appDispatch({ type: 'LOGIN_REQUEST' });
|
||||||
try {
|
try {
|
||||||
const { data: startData } = await api.post('/auth/passkeys/login/start', { username });
|
const { data: startData } = await api.post('/auth/passkeys/login/start', { username });
|
||||||
const challengeId = startData?.challengeId || startData?.challenge_id;
|
const challengeId = startData.challengeId;
|
||||||
const publicKeyOptions =
|
const publicKeyOptions = startData.publicKey;
|
||||||
startData?.publicKey
|
|
||||||
|| startData?.public_key
|
|
||||||
|| startData?.publicKeyCredentialRequestOptions
|
|
||||||
|| startData?.challenge?.publicKey
|
|
||||||
|| startData?.challenge?.public_key;
|
|
||||||
|
|
||||||
if (!challengeId || !publicKeyOptions) {
|
if (!challengeId || !publicKeyOptions) {
|
||||||
throw new Error('Invalid passkey challenge response.');
|
throw new Error('Invalid passkey challenge response.');
|
||||||
@@ -5828,7 +5827,7 @@ const LoginRoute = () => {
|
|||||||
if (error?.name === 'NotAllowedError') {
|
if (error?.name === 'NotAllowedError') {
|
||||||
setStatusMessage('Passkey login cancelled.', 'info');
|
setStatusMessage('Passkey login cancelled.', 'info');
|
||||||
} else if (error?.response?.status === 404) {
|
} else if (error?.response?.status === 404) {
|
||||||
setStatusMessage('No passkeys found for this user. Sign in with your password to add one.', 'error');
|
setStatusMessage('No passkey registered for this username. Create an account first.', 'error');
|
||||||
appDispatch({ type: 'LOGIN_FAILURE', error: 'passkey not registered' });
|
appDispatch({ type: 'LOGIN_FAILURE', error: 'passkey not registered' });
|
||||||
} else if (error?.response?.status === 400 && error?.response?.data?.error) {
|
} else if (error?.response?.status === 400 && error?.response?.data?.error) {
|
||||||
setStatusMessage(error.response.data.error, 'error');
|
setStatusMessage(error.response.data.error, 'error');
|
||||||
@@ -5845,6 +5844,85 @@ const LoginRoute = () => {
|
|||||||
[appDispatch, notifyLoginError, passkeySupported, setStatusMessage],
|
[appDispatch, notifyLoginError, passkeySupported, setStatusMessage],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleSignup = useCallback(
|
||||||
|
async (rawUsername) => {
|
||||||
|
const username = (rawUsername || '').trim();
|
||||||
|
if (!username) {
|
||||||
|
setStatusMessage('Choose a username to create your account.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!passkeySupported) {
|
||||||
|
setStatusMessage('Passkeys are not supported in this browser.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSignupLoading(true);
|
||||||
|
try {
|
||||||
|
const { data: startData } = await api.post('/auth/signup/start', { username });
|
||||||
|
const signupToken = startData.signup_token;
|
||||||
|
const challengePayload = startData.challenge;
|
||||||
|
const challengeId = challengePayload?.challengeId;
|
||||||
|
const publicKeyOptions = challengePayload?.publicKey;
|
||||||
|
|
||||||
|
if (!signupToken || !challengeId || !publicKeyOptions) {
|
||||||
|
throw new Error('Invalid signup challenge response.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicKey = preparePublicKeyCreationOptions({ publicKey: publicKeyOptions });
|
||||||
|
setStatusMessage('Confirm the passkey prompt to finish creating your account.', 'info');
|
||||||
|
const credential = await navigator.credentials.create({ publicKey });
|
||||||
|
|
||||||
|
if (!credential) {
|
||||||
|
setStatusMessage('Signup cancelled.', 'info');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const serialized = serializeRegistrationCredential(credential);
|
||||||
|
const finishPayload = {
|
||||||
|
signup_token: signupToken,
|
||||||
|
credential: serialized,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data: finishData } = await api.post('/auth/signup/finish', finishPayload);
|
||||||
|
|
||||||
|
if (finishData?.access_token && Array.isArray(finishData?.tenants)) {
|
||||||
|
appDispatch({
|
||||||
|
type: 'TENANT_SELECTION_REQUIRED',
|
||||||
|
selectionToken: finishData.access_token,
|
||||||
|
tenants: finishData.tenants,
|
||||||
|
});
|
||||||
|
setStatusMessage('Select a tenant to continue.', 'info');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!finishData?.access_token) {
|
||||||
|
throw new Error('Invalid signup response.');
|
||||||
|
}
|
||||||
|
|
||||||
|
appDispatch({
|
||||||
|
type: 'LOGIN_SUCCESS',
|
||||||
|
token: finishData.access_token,
|
||||||
|
tenant: finishData.tenant || null,
|
||||||
|
});
|
||||||
|
setStatusMessage('Account created. Welcome!', 'success');
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.name === 'NotAllowedError') {
|
||||||
|
setStatusMessage('Signup cancelled.', 'info');
|
||||||
|
} else if (error?.response?.status === 409) {
|
||||||
|
setStatusMessage('This passkey is already registered. Try signing in instead.', 'error');
|
||||||
|
} else if (error?.response?.data?.error) {
|
||||||
|
setStatusMessage(error.response.data.error, 'error');
|
||||||
|
} else {
|
||||||
|
const message = error?.message || 'Failed to create account.';
|
||||||
|
setStatusMessage(message, 'error');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSignupLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[appDispatch, passkeySupported, setStatusMessage],
|
||||||
|
);
|
||||||
|
|
||||||
const redirectTarget = useMemo(() => {
|
const redirectTarget = useMemo(() => {
|
||||||
const target = location.state?.from;
|
const target = location.state?.from;
|
||||||
if (typeof target === 'string' && target.startsWith('/')) {
|
if (typeof target === 'string' && target.startsWith('/')) {
|
||||||
@@ -5868,6 +5946,9 @@ const LoginRoute = () => {
|
|||||||
onPasskeyLogin={handlePasskeyLogin}
|
onPasskeyLogin={handlePasskeyLogin}
|
||||||
passkeySupported={passkeySupported}
|
passkeySupported={passkeySupported}
|
||||||
passkeyLoading={passkeyLoading}
|
passkeyLoading={passkeyLoading}
|
||||||
|
onSignup={handleSignup}
|
||||||
|
signupSupported={signupSupported}
|
||||||
|
signupLoading={signupLoading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,8 +12,11 @@ const LoginView = ({
|
|||||||
onCancelSelection,
|
onCancelSelection,
|
||||||
selectingTenantId,
|
selectingTenantId,
|
||||||
onPasskeyLogin,
|
onPasskeyLogin,
|
||||||
|
onSignup,
|
||||||
passkeySupported = false,
|
passkeySupported = false,
|
||||||
passkeyLoading = false,
|
passkeyLoading = false,
|
||||||
|
signupSupported = false,
|
||||||
|
signupLoading = false,
|
||||||
}) => {
|
}) => {
|
||||||
const hasTenantSelection = Boolean(tenantSelection?.tenants?.length);
|
const hasTenantSelection = Boolean(tenantSelection?.tenants?.length);
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
@@ -25,6 +28,21 @@ const LoginView = ({
|
|||||||
onPasskeyLogin(username);
|
onPasskeyLogin(username);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSignupClick = () => {
|
||||||
|
if (!onSignup) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSignup(username);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!passkeySupported || passkeyLoading || !username.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handlePasskeyClick();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="login-screen">
|
<div className="login-screen">
|
||||||
<div className="login-card">
|
<div className="login-card">
|
||||||
@@ -60,7 +78,8 @@ const LoginView = ({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<p>Use your registered passkey to sign in.</p>
|
<p>Use your registered passkey to sign in or create a new account.</p>
|
||||||
|
<form className="login-card__fields" onSubmit={handleSubmit}>
|
||||||
<label htmlFor="username">Username</label>
|
<label htmlFor="username">Username</label>
|
||||||
<input
|
<input
|
||||||
id="username"
|
id="username"
|
||||||
@@ -73,9 +92,8 @@ const LoginView = ({
|
|||||||
/>
|
/>
|
||||||
{passkeySupported ? (
|
{passkeySupported ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="submit"
|
||||||
className="secondary login-card__passkey-button"
|
className="login-card__passkey-button"
|
||||||
onClick={handlePasskeyClick}
|
|
||||||
disabled={passkeyLoading || !username.trim()}
|
disabled={passkeyLoading || !username.trim()}
|
||||||
>
|
>
|
||||||
{passkeyLoading ? 'Signing in…' : 'Sign in with passkey'}
|
{passkeyLoading ? 'Signing in…' : 'Sign in with passkey'}
|
||||||
@@ -83,6 +101,17 @@ const LoginView = ({
|
|||||||
) : (
|
) : (
|
||||||
<p className="settings-empty">Passkeys are not supported in this browser.</p>
|
<p className="settings-empty">Passkeys are not supported in this browser.</p>
|
||||||
)}
|
)}
|
||||||
|
</form>
|
||||||
|
{signupSupported ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="login-card__signup-button"
|
||||||
|
onClick={handleSignupClick}
|
||||||
|
disabled={signupLoading || !username.trim()}
|
||||||
|
>
|
||||||
|
{signupLoading ? 'Creating account…' : 'Create account with passkey'}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<StatusBanner status={status} />
|
<StatusBanner status={status} />
|
||||||
|
|||||||
@@ -2896,6 +2896,27 @@ form.inline {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.login-card__fields {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card__passkey-button {
|
||||||
|
margin-top: 1rem;
|
||||||
|
width: 100%;
|
||||||
|
display: inline-flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card__signup-button {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
width: 100%;
|
||||||
|
display: inline-flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
.login-card .status-banner {
|
.login-card .status-banner {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user