frontend signup
This commit is contained in:
+89
-8
@@ -40,7 +40,9 @@ import usePasskeys from './settings/usePasskeys';
|
||||
import {
|
||||
isWebAuthnAvailable,
|
||||
preparePublicKeyRequestOptions,
|
||||
preparePublicKeyCreationOptions,
|
||||
serializeAuthenticationCredential,
|
||||
serializeRegistrationCredential,
|
||||
} from './utils/webauthn';
|
||||
|
||||
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 passkeySupported = isWebAuthnAvailable();
|
||||
const [passkeyLoading, setPasskeyLoading] = useState(false);
|
||||
const signupSupported = passkeySupported;
|
||||
const [signupLoading, setSignupLoading] = useState(false);
|
||||
|
||||
const setStatusMessage = useCallback((message, variant = 'info') => {
|
||||
setStatus(message ? { message, variant } : null);
|
||||
@@ -5775,13 +5779,8 @@ const LoginRoute = () => {
|
||||
appDispatch({ type: 'LOGIN_REQUEST' });
|
||||
try {
|
||||
const { data: startData } = await api.post('/auth/passkeys/login/start', { username });
|
||||
const challengeId = startData?.challengeId || startData?.challenge_id;
|
||||
const publicKeyOptions =
|
||||
startData?.publicKey
|
||||
|| startData?.public_key
|
||||
|| startData?.publicKeyCredentialRequestOptions
|
||||
|| startData?.challenge?.publicKey
|
||||
|| startData?.challenge?.public_key;
|
||||
const challengeId = startData.challengeId;
|
||||
const publicKeyOptions = startData.publicKey;
|
||||
|
||||
if (!challengeId || !publicKeyOptions) {
|
||||
throw new Error('Invalid passkey challenge response.');
|
||||
@@ -5828,7 +5827,7 @@ const LoginRoute = () => {
|
||||
if (error?.name === 'NotAllowedError') {
|
||||
setStatusMessage('Passkey login cancelled.', 'info');
|
||||
} 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' });
|
||||
} else if (error?.response?.status === 400 && error?.response?.data?.error) {
|
||||
setStatusMessage(error.response.data.error, 'error');
|
||||
@@ -5845,6 +5844,85 @@ const LoginRoute = () => {
|
||||
[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 target = location.state?.from;
|
||||
if (typeof target === 'string' && target.startsWith('/')) {
|
||||
@@ -5868,6 +5946,9 @@ const LoginRoute = () => {
|
||||
onPasskeyLogin={handlePasskeyLogin}
|
||||
passkeySupported={passkeySupported}
|
||||
passkeyLoading={passkeyLoading}
|
||||
onSignup={handleSignup}
|
||||
signupSupported={signupSupported}
|
||||
signupLoading={signupLoading}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -12,8 +12,11 @@ const LoginView = ({
|
||||
onCancelSelection,
|
||||
selectingTenantId,
|
||||
onPasskeyLogin,
|
||||
onSignup,
|
||||
passkeySupported = false,
|
||||
passkeyLoading = false,
|
||||
signupSupported = false,
|
||||
signupLoading = false,
|
||||
}) => {
|
||||
const hasTenantSelection = Boolean(tenantSelection?.tenants?.length);
|
||||
const [username, setUsername] = useState('');
|
||||
@@ -25,6 +28,21 @@ const LoginView = ({
|
||||
onPasskeyLogin(username);
|
||||
};
|
||||
|
||||
const handleSignupClick = () => {
|
||||
if (!onSignup) {
|
||||
return;
|
||||
}
|
||||
onSignup(username);
|
||||
};
|
||||
|
||||
const handleSubmit = (event) => {
|
||||
event.preventDefault();
|
||||
if (!passkeySupported || passkeyLoading || !username.trim()) {
|
||||
return;
|
||||
}
|
||||
handlePasskeyClick();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-screen">
|
||||
<div className="login-card">
|
||||
@@ -60,29 +78,40 @@ const LoginView = ({
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p>Use your registered passkey to sign in.</p>
|
||||
<label htmlFor="username">Username</label>
|
||||
<input
|
||||
id="username"
|
||||
name="username"
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
placeholder="Username"
|
||||
autoComplete="username"
|
||||
required
|
||||
/>
|
||||
{passkeySupported ? (
|
||||
<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>
|
||||
<input
|
||||
id="username"
|
||||
name="username"
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
placeholder="Username"
|
||||
autoComplete="username"
|
||||
required
|
||||
/>
|
||||
{passkeySupported ? (
|
||||
<button
|
||||
type="submit"
|
||||
className="login-card__passkey-button"
|
||||
disabled={passkeyLoading || !username.trim()}
|
||||
>
|
||||
{passkeyLoading ? 'Signing in…' : 'Sign in with passkey'}
|
||||
</button>
|
||||
) : (
|
||||
<p className="settings-empty">Passkeys are not supported in this browser.</p>
|
||||
)}
|
||||
</form>
|
||||
{signupSupported ? (
|
||||
<button
|
||||
type="button"
|
||||
className="secondary login-card__passkey-button"
|
||||
onClick={handlePasskeyClick}
|
||||
disabled={passkeyLoading || !username.trim()}
|
||||
className="login-card__signup-button"
|
||||
onClick={handleSignupClick}
|
||||
disabled={signupLoading || !username.trim()}
|
||||
>
|
||||
{passkeyLoading ? 'Signing in…' : 'Sign in with passkey'}
|
||||
{signupLoading ? 'Creating account…' : 'Create account with passkey'}
|
||||
</button>
|
||||
) : (
|
||||
<p className="settings-empty">Passkeys are not supported in this browser.</p>
|
||||
)}
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
<StatusBanner status={status} />
|
||||
|
||||
@@ -2896,6 +2896,27 @@ form.inline {
|
||||
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 {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user