Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 61 additions & 74 deletions SparkyFitnessFrontend/src/api/Auth/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,36 @@ import { authClient } from '@/lib/auth-client';
import type { AccessibleUser, AuthResponse, LoginSettings } from '@/types/auth';
import { apiCall } from '../api';

interface AuthError extends Error {
code?: string;
status?: number;
}

interface BetterAuthUser {
id: string;
email: string;
name: string;
role?: string;
twoFactorEnabled?: boolean;
mfaEmailEnabled?: boolean;
}

interface BetterAuthResponse {
user: BetterAuthUser;
twoFactorRedirect?: boolean;
}

export interface IdentityUserResponse {
activeUserId: string;
fullName: string | null;
activeUserFullName?: string;
activeUserEmail: string;
}

export interface SwitchContextResponse {
activeUserId?: string;
}

export const requestMagicLink = async (email: string): Promise<void> => {
const { error } = await authClient.signIn.magicLink({
email,
Expand All @@ -23,22 +53,28 @@ export const registerUser = async (

if (error) {
if (error.status === 409) {
const err = new Error('User with this email already exists.');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(err as any).code = '23505';
const err = new Error(
'User with this email already exists.'
) as AuthError;
err.code = '23505';
throw err;
}
throw error;
}

const authData = data as BetterAuthResponse | null;

if (!authData?.user) {
throw new Error(
'Registration succeeded but no user data was received from the server.'
);
}

return {
message: 'User registered successfully',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
userId: (data as any)?.user?.id,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
role: ((data as any)?.user as any)?.role || 'user',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
fullName: (data as any)?.user?.name || '',
userId: authData?.user?.id,
role: authData?.user?.role || 'user',
fullName: authData?.user?.name || '',
} as AuthResponse;
};

Expand All @@ -58,31 +94,31 @@ export const loginUser = async (
throw error;
}

const authData = data as BetterAuthResponse | null;

if (!authData?.user) {
throw new Error(
'Login succeeded but no user data was received from the server.'
);
}

// Better Auth native 2FA handling
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if ((data as any)?.twoFactorRedirect) {
if (authData?.twoFactorRedirect) {
return {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
userId: (data as any)?.user?.id || '',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
email: (data as any)?.user?.email || email,
userId: authData?.user?.id || '',
email: authData?.user?.email || email,
status: 'MFA_REQUIRED',
twoFactorRedirect: true,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mfa_totp_enabled: (data as any)?.user?.twoFactorEnabled,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mfa_email_enabled: (data as any)?.user?.mfaEmailEnabled,
mfa_totp_enabled: authData?.user?.twoFactorEnabled,
mfa_email_enabled: authData?.user?.mfaEmailEnabled,
} as AuthResponse;
}

return {
message: 'Login successful',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
userId: (data as any)?.user?.id,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
role: ((data as any)?.user as any)?.role || 'user',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
fullName: (data as any)?.user?.name || '',
userId: authData?.user?.id,
role: authData?.user?.role || 'user',
fullName: authData?.user?.name || '',
} as AuthResponse;
};

Expand Down Expand Up @@ -141,61 +177,12 @@ export const getLoginSettings = async (): Promise<LoginSettings> => {
}
};

export const verifyMagicLink = async (token: string): Promise<AuthResponse> => {
// In Better Auth 1.0, verification can also be done via signIn.magicLink token property
// if the plugin is configured to support manual verification.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { data, error } = await (authClient as any).signIn.magicLink({
token,
});

if (error) throw error;

// Better Auth native 2FA handling after Magic Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if ((data as any)?.twoFactorRedirect) {
return {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
userId: (data as any)?.user?.id || '',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
email: (data as any)?.user?.email || '',
status: 'MFA_REQUIRED',
twoFactorRedirect: true,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mfa_totp_enabled: (data as any)?.user?.twoFactorEnabled,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mfa_email_enabled: (data as any)?.user?.mfaEmailEnabled,
} as AuthResponse;
}

return {
message: 'Magic link login successful',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
userId: (data as any)?.user?.id,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
role: ((data as any)?.user as any)?.role || 'user',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
fullName: (data as any)?.user?.name || '',
} as AuthResponse;
};

export const getMfaFactors = async (email: string) => {
return await apiCall(`/auth/mfa-factors?email=${encodeURIComponent(email)}`, {
method: 'GET',
});
};

export interface IdentityUserResponse {
activeUserId: string;
fullName: string | null;
activeUserFullName?: string;
activeUserEmail: string;
}

export interface SwitchContextResponse {
activeUserId?: string;
}

export const fetchIdentityUser = async (): Promise<IdentityUserResponse> => {
return apiCall('/identity/user', {
method: 'GET',
Expand Down
18 changes: 0 additions & 18 deletions SparkyFitnessFrontend/src/hooks/Auth/useAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import {
requestMagicLink,
registerUser,
loginUser,
verifyMagicLink,
getLoginSettings,
initiateOidcLogin,
resetPassword,
Expand Down Expand Up @@ -72,23 +71,6 @@ export const useRequestMagicLinkMutation = () => {
});
};

export const useVerifyMagicLinkMutation = () => {
const { t } = useTranslation();
return useMutation({
mutationFn: verifyMagicLink,
meta: {
successMessage: t(
'auth.magicLinkVerifySuccess',
'Logged in via magic link!'
),
errorMessage: t(
'auth.magicLinkVerifyError',
'Magic link is invalid or expired.'
),
},
});
};

export const useAuthSettings = () => {
return useQuery({
queryKey: authKeys.settings,
Expand Down
104 changes: 1 addition & 103 deletions SparkyFitnessFrontend/src/pages/Auth/Auth.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
Expand Down Expand Up @@ -28,7 +28,6 @@ import {
useLoginUserMutation,
useRegisterUserMutation,
useRequestMagicLinkMutation,
useVerifyMagicLinkMutation,
} from '@/hooks/Auth/useAuth';
import { MagicLinkRequestDialog } from './MagicLinkRequestDialog';
import { useQueryClient } from '@tanstack/react-query';
Expand Down Expand Up @@ -61,7 +60,6 @@ const Auth = () => {
const { mutateAsync: loginUser } = useLoginUserMutation();
const { mutateAsync: registerUser } = useRegisterUserMutation();
const { mutateAsync: requestMagicLink } = useRequestMagicLinkMutation();
const { mutateAsync: verifyMagicLink } = useVerifyMagicLinkMutation();
const { mutateAsync: initiateOidcLogin } = useInitiateOidcLoginMutation();

useEffect(() => {
Expand Down Expand Up @@ -241,106 +239,6 @@ const Auth = () => {
[loggingLevel, queryClient]
);

const hasAttemptedMagicLinkLogin = useRef(false);

useEffect(() => {
const params = new URLSearchParams(window.location.search);
const magicLinkToken = params.get('token');
const authError = params.get('error');
const path = window.location.pathname;

// Handle authentication errors from redirects
if (authError) {
const errorMsg =
authError === 'signup disabled'
? 'New registrations are currently disabled for this provider.'
: `Authentication failed: ${authError}`;

toast({
title: 'Login Error',
description: errorMsg,
variant: 'destructive',
});
// Clear the error from URL by redirecting to base path
navigate('/');
}

if (
path === '/login/magic-link' &&
magicLinkToken &&
!hasAttemptedMagicLinkLogin.current
) {
hasAttemptedMagicLinkLogin.current = true;
info(loggingLevel, 'Auth: Attempting magic link login.');
setLoading(true);
const handleMagicLinkLogin = async () => {
try {
const data: AuthResponse = await verifyMagicLink(magicLinkToken);

if (data.status === 'MFA_REQUIRED') {
const mfaShown = await triggerMfaChallenge(data, email, {
onMfaSuccess: () => {
setShowMfaChallenge(false);
navigate('/');
},
onMfaCancel: () => {
setShowMfaChallenge(false);
setLoading(false);
navigate('/'); // Redirect back to home page on cancel
},
});

if (!mfaShown) {
signIn(
data.userId,
data.userId,
data.email || email,
data.role || 'user',
'magic_link',
true,
data.fullName
);
}
} else {
info(loggingLevel, 'Auth: Magic link login successful.');
toast({
title: 'Success',
description: 'Logged in successfully via magic link!',
});
signIn(
data.userId,
data.userId,
data.email || email,
data.role || 'user',
'magic_link',
true,
data.fullName
);
}
} catch (err: unknown) {
const message = getErrorMessage(err);
error(loggingLevel, 'Auth: Magic link login failed:', err);
toast({
title: 'Error',
description: message || 'Magic link is invalid or has expired.',
variant: 'destructive',
});
window.location.replace('/'); // Force a full page reload to clear state
} finally {
setLoading(false);
}
};
handleMagicLinkLogin();
}
}, [
loggingLevel,
navigate,
signIn,
email,
triggerMfaChallenge,
verifyMagicLink,
]);

const validatePassword = (pwd: string) => {
if (pwd.length < 6) {
return 'Password must be at least 6 characters long.';
Expand Down