magic token login
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import LoginView from '../login/LoginView';
|
||||
import useApiError from '../hooks/useApiError';
|
||||
@@ -21,6 +21,60 @@ const LoginRoute = () => {
|
||||
const [passkeyLoading, setPasskeyLoading] = useState(false);
|
||||
const signupSupported = passkeySupported;
|
||||
const [signupLoading, setSignupLoading] = useState(false);
|
||||
const [magicLoginPending, setMagicLoginPending] = useState(false);
|
||||
const magicLoginParams = useMemo(() => {
|
||||
const extract = (searchString) => {
|
||||
const params = new URLSearchParams(searchString || '');
|
||||
const token = (params.get('magic_token') || '').trim();
|
||||
const usernameHint = (params.get('username') || '').trim();
|
||||
const preferredTenantId = (params.get('preferred_tenant_id') || '').trim();
|
||||
return {
|
||||
token: token || null,
|
||||
username: usernameHint || null,
|
||||
preferredTenantId: preferredTenantId || null,
|
||||
};
|
||||
};
|
||||
|
||||
let combined = extract(location.search);
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
const hash = window.location.hash || '';
|
||||
const queryIndex = hash.indexOf('?');
|
||||
if (queryIndex !== -1) {
|
||||
const hashQuery = hash.slice(queryIndex + 1);
|
||||
const hashParams = extract(`?${hashQuery}`);
|
||||
combined = {
|
||||
token: combined.token || hashParams.token,
|
||||
username: combined.username || hashParams.username,
|
||||
preferredTenantId: combined.preferredTenantId || hashParams.preferredTenantId,
|
||||
};
|
||||
}
|
||||
if (!combined.token) {
|
||||
const searchParams = extract(window.location.search);
|
||||
combined = {
|
||||
token: combined.token || searchParams.token,
|
||||
username: combined.username || searchParams.username,
|
||||
preferredTenantId: combined.preferredTenantId || searchParams.preferredTenantId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return combined;
|
||||
}, [location.search]);
|
||||
const preferredTenantRef = useRef(null);
|
||||
const attemptedMagicTokenRef = useRef(null);
|
||||
const magicLoginPendingRef = useRef(false);
|
||||
const appStatusRef = useRef(appStatus);
|
||||
|
||||
const {
|
||||
token: magicToken,
|
||||
username: magicUsername,
|
||||
preferredTenantId: magicPreferredTenantId,
|
||||
} = magicLoginParams;
|
||||
|
||||
useEffect(() => {
|
||||
appStatusRef.current = appStatus;
|
||||
}, [appStatus]);
|
||||
|
||||
const setStatusMessage = useCallback((message, variant = 'info') => {
|
||||
setStatus(message ? { message, variant } : null);
|
||||
@@ -41,6 +95,50 @@ const LoginRoute = () => {
|
||||
[reportLoginError],
|
||||
);
|
||||
|
||||
const clearMagicParamsFromUrl = useCallback(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const removableKeys = ['magic_token', 'username', 'preferred_tenant_id'];
|
||||
const currentSearch = new URLSearchParams(window.location.search);
|
||||
let searchChanged = false;
|
||||
removableKeys.forEach((key) => {
|
||||
if (currentSearch.has(key)) {
|
||||
currentSearch.delete(key);
|
||||
searchChanged = true;
|
||||
}
|
||||
});
|
||||
|
||||
const hash = window.location.hash || '';
|
||||
let nextHash = hash;
|
||||
const hashQuestionIndex = hash.indexOf('?');
|
||||
if (hashQuestionIndex !== -1) {
|
||||
const hashPath = hash.slice(0, hashQuestionIndex);
|
||||
const hashQuery = hash.slice(hashQuestionIndex + 1);
|
||||
const hashParams = new URLSearchParams(hashQuery);
|
||||
let hashChanged = false;
|
||||
removableKeys.forEach((key) => {
|
||||
if (hashParams.has(key)) {
|
||||
hashParams.delete(key);
|
||||
hashChanged = true;
|
||||
}
|
||||
});
|
||||
if (hashChanged) {
|
||||
const nextQuery = hashParams.toString();
|
||||
nextHash = nextQuery ? `${hashPath}?${nextQuery}` : hashPath;
|
||||
}
|
||||
}
|
||||
|
||||
if (!searchChanged && nextHash === hash) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextSearch = currentSearch.toString();
|
||||
const nextUrl = `${window.location.pathname}${nextSearch ? `?${nextSearch}` : ''}${nextHash}`;
|
||||
window.history.replaceState(window.history.state, document.title, nextUrl);
|
||||
}, []);
|
||||
|
||||
const handleTenantSelect = useCallback(
|
||||
async (tenant) => {
|
||||
if (!tenantSelection?.selectionToken || !tenant?.id) {
|
||||
@@ -165,6 +263,122 @@ const LoginRoute = () => {
|
||||
[appDispatch, notifyLoginError, passkeySupported, setStatusMessage],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!magicToken) {
|
||||
return;
|
||||
}
|
||||
if (magicLoginPendingRef.current) {
|
||||
return;
|
||||
}
|
||||
if (attemptedMagicTokenRef.current === magicToken) {
|
||||
return;
|
||||
}
|
||||
if (appStatusRef.current === 'authenticated') {
|
||||
clearMagicParamsFromUrl();
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
attemptedMagicTokenRef.current = magicToken;
|
||||
|
||||
const attemptMagicLogin = async () => {
|
||||
magicLoginPendingRef.current = true;
|
||||
setMagicLoginPending(true);
|
||||
preferredTenantRef.current = magicPreferredTenantId;
|
||||
appDispatch({ type: 'LOGIN_REQUEST' });
|
||||
setStatusMessage('Signing you in…', 'info');
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
magic_token: magicToken,
|
||||
};
|
||||
if (magicUsername) {
|
||||
payload.username = magicUsername;
|
||||
}
|
||||
if (magicPreferredTenantId) {
|
||||
payload.preferred_tenant_id = magicPreferredTenantId;
|
||||
}
|
||||
|
||||
const { data } = await api.post('/auth/login', payload);
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (data?.access_token && Array.isArray(data?.tenants)) {
|
||||
appDispatch({
|
||||
type: 'TENANT_SELECTION_REQUIRED',
|
||||
selectionToken: data.access_token,
|
||||
tenants: data.tenants,
|
||||
});
|
||||
setStatusMessage('Select a tenant to continue.', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data?.access_token) {
|
||||
throw new Error('Invalid login response.');
|
||||
}
|
||||
|
||||
appDispatch({
|
||||
type: 'LOGIN_SUCCESS',
|
||||
token: data.access_token,
|
||||
tenant: data.tenant || null,
|
||||
});
|
||||
setStatusMessage('Login successful.', 'success');
|
||||
preferredTenantRef.current = null;
|
||||
} catch (error) {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
const message = error?.response?.data?.error || 'Magic link login failed.';
|
||||
notifyLoginError(error, message);
|
||||
appDispatch({ type: 'LOGIN_FAILURE', error: message });
|
||||
preferredTenantRef.current = null;
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setMagicLoginPending(false);
|
||||
magicLoginPendingRef.current = false;
|
||||
clearMagicParamsFromUrl();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
attemptMagicLogin();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
magicLoginPendingRef.current = false;
|
||||
setMagicLoginPending(false);
|
||||
};
|
||||
}, [
|
||||
appDispatch,
|
||||
clearMagicParamsFromUrl,
|
||||
magicToken,
|
||||
magicUsername,
|
||||
magicPreferredTenantId,
|
||||
notifyLoginError,
|
||||
setStatusMessage,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const preferredTenantId = preferredTenantRef.current;
|
||||
if (!preferredTenantId) {
|
||||
return;
|
||||
}
|
||||
if (!tenantSelection?.tenants?.length) {
|
||||
return;
|
||||
}
|
||||
if (selectingTenantId) {
|
||||
return;
|
||||
}
|
||||
const match = tenantSelection.tenants.find((tenant) => tenant.id === preferredTenantId);
|
||||
if (!match) {
|
||||
preferredTenantRef.current = null;
|
||||
return;
|
||||
}
|
||||
handleTenantSelect(match);
|
||||
preferredTenantRef.current = null;
|
||||
}, [handleTenantSelect, selectingTenantId, tenantSelection]);
|
||||
|
||||
const handleSignup = useCallback(
|
||||
async (rawUsername) => {
|
||||
const username = typeof rawUsername === 'string' ? rawUsername.trim() : '';
|
||||
@@ -265,12 +479,14 @@ const LoginRoute = () => {
|
||||
onCancelSelection={handleCancelSelection}
|
||||
selectingTenantId={selectingTenantId}
|
||||
onPasskeyLogin={handlePasskeyLogin}
|
||||
passkeySupported={passkeySupported}
|
||||
passkeyLoading={passkeyLoading}
|
||||
onSignup={handleSignup}
|
||||
signupSupported={signupSupported}
|
||||
signupLoading={signupLoading}
|
||||
/>
|
||||
passkeySupported={passkeySupported}
|
||||
passkeyLoading={passkeyLoading}
|
||||
onSignup={handleSignup}
|
||||
signupSupported={signupSupported}
|
||||
signupLoading={signupLoading}
|
||||
magicLoginPending={magicLoginPending}
|
||||
initialUsername={magicLoginParams.username || ''}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
const StatusBanner = ({ status }) => {
|
||||
if (!status) return null;
|
||||
@@ -17,9 +17,15 @@ const LoginView = ({
|
||||
passkeyLoading = false,
|
||||
signupSupported = false,
|
||||
signupLoading = false,
|
||||
magicLoginPending = false,
|
||||
initialUsername = '',
|
||||
}) => {
|
||||
const hasTenantSelection = Boolean(tenantSelection?.tenants?.length);
|
||||
const [username, setUsername] = useState('');
|
||||
const [username, setUsername] = useState(initialUsername);
|
||||
|
||||
useEffect(() => {
|
||||
setUsername(initialUsername);
|
||||
}, [initialUsername]);
|
||||
|
||||
const handlePasskeyClick = () => {
|
||||
if (!onPasskeyLogin) {
|
||||
@@ -37,7 +43,7 @@ const LoginView = ({
|
||||
|
||||
const handleSubmit = (event) => {
|
||||
event.preventDefault();
|
||||
if (!passkeySupported || passkeyLoading || !username.trim()) {
|
||||
if (!passkeySupported || passkeyLoading || magicLoginPending || !username.trim()) {
|
||||
return;
|
||||
}
|
||||
handlePasskeyClick();
|
||||
@@ -88,13 +94,14 @@ const LoginView = ({
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
placeholder="Username"
|
||||
autoComplete="username"
|
||||
disabled={passkeyLoading || magicLoginPending}
|
||||
required
|
||||
/>
|
||||
{passkeySupported ? (
|
||||
<button
|
||||
type="submit"
|
||||
className="login-card__passkey-button"
|
||||
disabled={passkeyLoading || !username.trim()}
|
||||
disabled={passkeyLoading || magicLoginPending || !username.trim()}
|
||||
>
|
||||
{passkeyLoading ? 'Signing in…' : 'Sign in with passkey'}
|
||||
</button>
|
||||
@@ -107,7 +114,7 @@ const LoginView = ({
|
||||
type="button"
|
||||
className="login-card__signup-button"
|
||||
onClick={handleSignupClick}
|
||||
disabled={signupLoading || !username.trim()}
|
||||
disabled={signupLoading || magicLoginPending || !username.trim()}
|
||||
>
|
||||
{signupLoading ? 'Creating account…' : 'Create account with passkey'}
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user