magic token login

This commit is contained in:
2025-10-31 15:16:30 +01:00
parent 91dd3289a9
commit 48bed9a9fe
10 changed files with 719 additions and 92 deletions
+223 -7
View File
@@ -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>
);
};
+12 -5
View File
@@ -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>