frontend signup

This commit is contained in:
2025-10-30 01:33:31 +01:00
parent 84f4cd05f4
commit b9966f82a0
3 changed files with 158 additions and 27 deletions
+89 -8
View File
@@ -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>
);
+48 -19
View File
@@ -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} />
+21
View File
@@ -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;
}