Files
papercrate/frontend/src/app/LoginRoute.tsx
T
2025-11-13 02:37:16 +01:00

523 lines
17 KiB
TypeScript

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';
import {
isWebAuthnAvailable,
preparePublicKeyRequestOptions,
preparePublicKeyCreationOptions,
serializeAuthenticationCredential,
serializeRegistrationCredential,
} from '../utils/webauthn';
import { api, useAppDispatch, useAppState } from './appState';
type StatusVariant = 'info' | 'success' | 'error';
interface StatusMessage {
message: string;
variant: StatusVariant;
}
interface TenantOption {
id?: string | number | null;
name?: string | null;
}
interface TenantSelectionState {
selectionToken?: string | null;
tenants?: TenantOption[];
}
const LoginRoute: React.FC = () => {
const appState = useAppState();
const { status: appStatus } = appState;
const tenantSelection = (appState.tenantSelection ?? null) as TenantSelectionState | null;
const appDispatch = useAppDispatch();
const location = useLocation();
const [status, setStatus] = useState<StatusMessage | null>(null);
const [selectingTenantId, setSelectingTenantId] = useState(null);
const passkeySupported = isWebAuthnAvailable();
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);
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);
}, []);
const handleLoginApiReport = useCallback(
({ message, variant }) => setStatusMessage(message, variant),
[setStatusMessage],
);
const reportLoginError = useApiError({
onReport: handleLoginApiReport,
});
const notifyLoginError = useCallback(
(error, fallbackMessage, variant = 'error') =>
reportLoginError(error, { message: fallbackMessage, variant }),
[reportLoginError],
);
const clearMagicParamsFromUrl = useCallback(() => {
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) {
return;
}
try {
setSelectingTenantId(tenant.id);
const { data } = await api.post(
'/auth/select-tenant',
{ tenant_id: tenant.id },
{
headers: {
Authorization: `Bearer ${tenantSelection.selectionToken}`,
},
},
);
if (!data?.access_token) {
throw new Error('Invalid tenant selection response.');
}
appDispatch({
type: 'LOGIN_SUCCESS',
token: data.access_token,
tenant: data.tenant || null,
});
setStatusMessage('Login successful.', 'success');
} catch (error) {
const message = error?.response?.data?.error || 'Failed to finalize login.';
notifyLoginError(error, message, 'error');
} finally {
setSelectingTenantId(null);
}
},
[appDispatch, notifyLoginError, setStatusMessage, tenantSelection],
);
const handleCancelSelection = useCallback(() => {
appDispatch({ type: 'CLEAR_TENANT_SELECTION' });
setStatusMessage(null);
}, [appDispatch, setStatusMessage]);
const handlePasskeyLogin = useCallback(
async (rawUsername) => {
const username = rawUsername?.trim?.() || '';
if (!username) {
setStatusMessage('Enter your username before using a passkey.', 'error');
return;
}
if (!passkeySupported) {
setStatusMessage('Passkeys are not supported in this browser.', 'error');
return;
}
setPasskeyLoading(true);
appDispatch({ type: 'LOGIN_REQUEST' });
try {
const { data: startData } = await api.post('/auth/passkeys/login/start', { username });
const challengeId = startData.challengeId;
const publicKeyOptions = startData.publicKey;
if (!challengeId || !publicKeyOptions) {
throw new Error('Invalid passkey challenge response.');
}
const publicKey = preparePublicKeyRequestOptions({ publicKey: publicKeyOptions });
setStatusMessage('Confirm the passkey prompt to continue.', 'info');
const assertion = await navigator.credentials.get({ publicKey }) as PublicKeyCredential | null;
if (!assertion) {
setStatusMessage('Passkey login cancelled.', 'info');
return;
}
if (!(assertion instanceof PublicKeyCredential)) {
setStatusMessage('Unexpected credential response.', 'error');
return;
}
const serialized = serializeAuthenticationCredential(assertion);
const finishPayload = {
challengeId,
credential: serialized,
};
const { data: finishData } = await api.post('/auth/passkeys/login/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 login response.');
}
appDispatch({
type: 'LOGIN_SUCCESS',
token: finishData.access_token,
tenant: finishData.tenant || null,
});
setStatusMessage('Login successful.', 'success');
} catch (error) {
if (error?.name === 'NotAllowedError') {
setStatusMessage('Passkey login cancelled.', 'info');
} else if (error?.response?.status === 404) {
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');
appDispatch({ type: 'LOGIN_FAILURE', error: error.response.data.error });
} else {
const message = error?.response?.data?.error || error.message || 'Passkey login failed.';
notifyLoginError(error, message);
appDispatch({ type: 'LOGIN_FAILURE', error: message });
}
} finally {
setPasskeyLoading(false);
}
},
[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: string;
username?: string;
preferred_tenant_id?: string | number;
} = {
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 = 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 }) as PublicKeyCredential | null;
if (!credential) {
setStatusMessage('Signup cancelled.', 'info');
return;
}
if (!(credential instanceof PublicKeyCredential)) {
setStatusMessage('Unexpected credential response.', 'error');
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 = String(location.state?.from ?? '');
if (target.startsWith('/')) {
return target;
}
return '/documents';
}, [location.state]);
if (!['logged-out', 'authenticating', 'selecting-tenant'].includes(appStatus)) {
return <Navigate to={redirectTarget} replace />;
}
return (
<div className="app-shell">
<LoginView
status={status}
tenantSelection={tenantSelection}
onSelectTenant={handleTenantSelect}
onCancelSelection={handleCancelSelection}
selectingTenantId={selectingTenantId}
onPasskeyLogin={handlePasskeyLogin}
passkeySupported={passkeySupported}
passkeyLoading={passkeyLoading}
onSignup={handleSignup}
signupSupported={signupSupported}
signupLoading={signupLoading}
magicLoginPending={magicLoginPending}
initialUsername={magicLoginParams.username || ''}
/>
</div>
);
};
export default LoginRoute;