feat(auth): unify sign-in options
This commit is contained in:
@@ -47,7 +47,7 @@ function loadGoogleScript(): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void }) {
|
||||
export default function GoogleAuthCard({ onSignedIn, presentation = "account" }: { onSignedIn?: () => void; presentation?: "account" | "sign-in" }) {
|
||||
const { toast } = useToast();
|
||||
const { t } = useI18n();
|
||||
const [me, setMe] = useState<MeResponse | null>(null);
|
||||
@@ -57,8 +57,9 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void
|
||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const clientId = (process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || "").trim();
|
||||
const signInOnly = presentation === "sign-in";
|
||||
const signedIn = Boolean(me?.provider);
|
||||
const actionLabel = !signedIn
|
||||
const actionLabel = signInOnly || !signedIn
|
||||
? t("continueWithGoogle")
|
||||
: me?.provider === "local" && !me?.googleLink?.linked
|
||||
? t("linkWithGoogle")
|
||||
@@ -74,23 +75,25 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (signInOnly) return;
|
||||
void refreshMe();
|
||||
api.get<{ allowRegistration: boolean }>("/auth/config").then((res) => {
|
||||
setAllowRegistration(Boolean(res.data?.allowRegistration));
|
||||
}).catch(() => setAllowRegistration(false));
|
||||
}, []);
|
||||
}, [signInOnly]);
|
||||
|
||||
useEffect(() => {
|
||||
if (signInOnly) return;
|
||||
const onAuthChanged = () => { void refreshMe(); };
|
||||
window.addEventListener("auth-changed", onAuthChanged);
|
||||
return () => window.removeEventListener("auth-changed", onAuthChanged);
|
||||
}, []);
|
||||
}, [signInOnly]);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
if (!clientId || !host) return;
|
||||
|
||||
const shouldRenderButton = !signedIn || (me?.provider === "local" && !me?.googleLink?.linked);
|
||||
const shouldRenderButton = signInOnly || !signedIn || (me?.provider === "local" && !me?.googleLink?.linked);
|
||||
host.replaceChildren();
|
||||
if (!shouldRenderButton) return;
|
||||
|
||||
@@ -106,7 +109,7 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void
|
||||
if (!credential) return;
|
||||
setWorking(true);
|
||||
try {
|
||||
if (me?.provider === "local") {
|
||||
if (!signInOnly && me?.provider === "local") {
|
||||
const res = await api.post<{ linked: boolean; email?: string | null }>("/auth/google/link", { token: credential, rememberMe: getAuthPersistencePreference() === "local" });
|
||||
toast(res.data?.email ? t("googleLinkedSuccessWithEmail", { email: res.data.email }) : t("googleLinkedSuccess"), "success");
|
||||
await refreshMe();
|
||||
@@ -132,7 +135,7 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void
|
||||
size: "large",
|
||||
type: "standard",
|
||||
shape: "pill",
|
||||
text: me?.provider === "local" ? "continue_with" : "signin_with",
|
||||
text: !signInOnly && me?.provider === "local" ? "continue_with" : "signin_with",
|
||||
});
|
||||
})
|
||||
.catch(() => toast(t("googleScriptLoadFailed"), "error"));
|
||||
@@ -141,10 +144,29 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void
|
||||
active = false;
|
||||
host.replaceChildren();
|
||||
};
|
||||
}, [clientId, me?.provider, me?.googleLink?.linked, onSignedIn, signedIn, toast, t]);
|
||||
}, [clientId, me?.provider, me?.googleLink?.linked, onSignedIn, signInOnly, signedIn, toast, t]);
|
||||
|
||||
const signedInName = me?.userName || me?.displayName || [me?.firstName, me?.lastName].filter(Boolean).join(" ") || me?.email || "";
|
||||
|
||||
if (signInOnly) {
|
||||
if (pendingToken) {
|
||||
return <TwoFactorChallenge
|
||||
pendingToken={pendingToken}
|
||||
onCancel={() => setPendingToken(null)}
|
||||
onSuccess={() => {
|
||||
setPendingToken(null);
|
||||
window.dispatchEvent(new Event("auth-changed"));
|
||||
toast(t("googleSignedIn"), "success");
|
||||
onSignedIn?.();
|
||||
}}
|
||||
/>;
|
||||
}
|
||||
|
||||
return clientId
|
||||
? <Box aria-label={actionLabel} aria-busy={working} sx={{ display: "flex", justifyContent: "center", minHeight: 40 }}><div ref={hostRef} /></Box>
|
||||
: <Button fullWidth variant="outlined" disabled>{actionLabel}</Button>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper sx={{ mt: 2, p: 2 }}>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||
|
||||
@@ -32,7 +32,7 @@ export function getMicrosoftMsalInstance(clientId: string): PublicClientApplicat
|
||||
return msalInstance;
|
||||
}
|
||||
|
||||
export default function MicrosoftAuthCard({ onSignedIn }: { onSignedIn?: () => void }) {
|
||||
export default function MicrosoftAuthCard({ onSignedIn, presentation = "account" }: { onSignedIn?: () => void; presentation?: "account" | "sign-in" }) {
|
||||
const { toast } = useToast();
|
||||
const { t } = useI18n();
|
||||
const [me, setMe] = useState<MeResponse | null>(null);
|
||||
@@ -41,8 +41,9 @@ export default function MicrosoftAuthCard({ onSignedIn }: { onSignedIn?: () => v
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
|
||||
const clientId = (process.env.NEXT_PUBLIC_MICROSOFT_CLIENT_ID || "").trim();
|
||||
const signInOnly = presentation === "sign-in";
|
||||
const signedIn = Boolean(me?.provider);
|
||||
const actionLabel = !signedIn
|
||||
const actionLabel = signInOnly || !signedIn
|
||||
? t("continueWithMicrosoft")
|
||||
: me?.provider === "local" && !me?.microsoftLink?.linked
|
||||
? t("linkWithMicrosoft")
|
||||
@@ -58,14 +59,16 @@ export default function MicrosoftAuthCard({ onSignedIn }: { onSignedIn?: () => v
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (signInOnly) return;
|
||||
void refreshMe();
|
||||
}, []);
|
||||
}, [signInOnly]);
|
||||
|
||||
useEffect(() => {
|
||||
if (signInOnly) return;
|
||||
const onAuthChanged = () => { void refreshMe(); };
|
||||
window.addEventListener("auth-changed", onAuthChanged);
|
||||
return () => window.removeEventListener("auth-changed", onAuthChanged);
|
||||
}, []);
|
||||
}, [signInOnly]);
|
||||
|
||||
async function handleSignIn() {
|
||||
if (!clientId) return;
|
||||
@@ -77,7 +80,7 @@ export default function MicrosoftAuthCard({ onSignedIn }: { onSignedIn?: () => v
|
||||
const idToken = result.idToken;
|
||||
if (!idToken) throw new Error(t("microsoftAuthFailed"));
|
||||
|
||||
if (me?.provider === "local") {
|
||||
if (!signInOnly && me?.provider === "local") {
|
||||
const res = await api.post<{ linked: boolean; email?: string | null }>("/auth/microsoft/link", { token: idToken, currentPassword });
|
||||
toast(res.data?.email ? t("microsoftLinkedSuccessWithEmail", { email: res.data.email }) : t("microsoftLinkedSuccess"), "success");
|
||||
clearAuthClientState();
|
||||
@@ -107,6 +110,23 @@ export default function MicrosoftAuthCard({ onSignedIn }: { onSignedIn?: () => v
|
||||
|
||||
const signedInName = me?.userName || me?.displayName || [me?.firstName, me?.lastName].filter(Boolean).join(" ") || me?.email || "";
|
||||
|
||||
if (signInOnly) {
|
||||
if (pendingToken) {
|
||||
return <TwoFactorChallenge
|
||||
pendingToken={pendingToken}
|
||||
onCancel={() => setPendingToken(null)}
|
||||
onSuccess={() => {
|
||||
setPendingToken(null);
|
||||
window.dispatchEvent(new Event("auth-changed"));
|
||||
toast(t("microsoftSignedIn"), "success");
|
||||
onSignedIn?.();
|
||||
}}
|
||||
/>;
|
||||
}
|
||||
|
||||
return <Button fullWidth variant="outlined" disabled={working || !clientId} onClick={() => void handleSignIn()}>{actionLabel}</Button>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper sx={{ mt: 2, p: 2 }}>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||
|
||||
@@ -219,6 +219,9 @@ export const translations = {
|
||||
profileFirstName: "First name",
|
||||
profileLastName: "Last name",
|
||||
profileEmail: "Email",
|
||||
usernameOrEmail: "Username or email",
|
||||
usernameOrEmailRequired: "Username or email is required",
|
||||
or: "or",
|
||||
profileNewEmail: "New email",
|
||||
profileCurrentEmail: "Current email: {email}",
|
||||
profileEmailChangePassword: "Password to confirm email change",
|
||||
@@ -1346,6 +1349,9 @@ export const translations = {
|
||||
profileFirstName: "Fornavn",
|
||||
profileLastName: "Etternavn",
|
||||
profileEmail: "E-post",
|
||||
usernameOrEmail: "Brukernavn eller e-post",
|
||||
usernameOrEmailRequired: "Brukernavn eller e-post er påkrevd",
|
||||
or: "eller",
|
||||
profileNewEmail: "Ny e-post",
|
||||
profileCurrentEmail: "Nåværende e-post: {email}",
|
||||
profileEmailChangePassword: "Passord for å bekrefte e-postendring",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React from 'react';
|
||||
import '@testing-library/jest-dom';
|
||||
jest.unmock('./components/GoogleAuthCard');
|
||||
jest.unmock('./components/MicrosoftAuthCard');
|
||||
import { act, render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
@@ -10,6 +12,15 @@ import { I18nProvider } from './i18n/I18nProvider';
|
||||
import { api } from './api';
|
||||
|
||||
const mockNavigate = jest.fn();
|
||||
const mockMsalInitialize = jest.fn();
|
||||
const mockMsalLoginPopup = jest.fn();
|
||||
|
||||
jest.mock('@azure/msal-browser', () => ({
|
||||
PublicClientApplication: jest.fn().mockImplementation(() => ({
|
||||
initialize: mockMsalInitialize,
|
||||
loginPopup: mockMsalLoginPopup,
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
@@ -62,22 +73,116 @@ describe('LoginPage', () => {
|
||||
});
|
||||
window.localStorage.clear();
|
||||
window.sessionStorage.clear();
|
||||
mockedApi.get.mockReset();
|
||||
mockedApi.get.mockImplementation((url: string) => {
|
||||
if (url === '/auth/config') return Promise.resolve({ data: { requireAuth: true, googleEnabled: false, microsoftEnabled: false, localEnabled: true, allowRegistration: true, requireEmailVerification: true } } as any);
|
||||
if (url === '/auth/me') return Promise.resolve({ data: { roles: [], email: 'person@example.com', userName: 'person' } } as any);
|
||||
return Promise.resolve({ data: {} } as any);
|
||||
});
|
||||
mockedApi.post.mockReset();
|
||||
mockNavigate.mockReset();
|
||||
mockMsalInitialize.mockReset().mockResolvedValue(undefined);
|
||||
mockMsalLoginPopup.mockReset();
|
||||
delete process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID;
|
||||
delete process.env.NEXT_PUBLIC_MICROSOFT_CLIENT_ID;
|
||||
delete (window as any).google;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('renders one conventional sign-in card with enabled provider alternatives', async () => {
|
||||
mockedApi.get.mockImplementation((url: string) => url === '/auth/config'
|
||||
? Promise.resolve({ data: { requireAuth: true, googleEnabled: true, microsoftEnabled: true, localEnabled: true, allowRegistration: true, requireEmailVerification: true } } as any)
|
||||
: Promise.reject({ response: { status: 401 } }));
|
||||
|
||||
renderLoginPage();
|
||||
|
||||
expect(await screen.findByLabelText('Username or email')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Current password')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Sign in' })).toBeInTheDocument();
|
||||
expect(await screen.findByText('or')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Continue with Google' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Continue with Microsoft' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('tab')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Google account')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Available to link')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('accepts a username and reports invalid credentials without changing routes', async () => {
|
||||
mockedApi.get.mockImplementation((url: string) => url === '/auth/config'
|
||||
? Promise.resolve({ data: { requireAuth: true, googleEnabled: false, microsoftEnabled: false, localEnabled: true, allowRegistration: true, requireEmailVerification: true } } as any)
|
||||
: Promise.reject({ response: { status: 401 } }));
|
||||
mockedApi.post.mockRejectedValueOnce({ response: { status: 401, data: 'Invalid credentials.' } });
|
||||
|
||||
renderLoginPage();
|
||||
await userEvent.type(await screen.findByLabelText('Username or email'), 'demo-user');
|
||||
await userEvent.type(screen.getByLabelText('Current password'), 'wrong-password');
|
||||
await click(screen.getByRole('button', { name: 'Sign in' }));
|
||||
|
||||
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/auth/login', { email: 'demo-user', password: 'wrong-password', rememberMe: true }));
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Invalid credentials.');
|
||||
expect(mockNavigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles Microsoft cancellation without exchanging or navigating', async () => {
|
||||
process.env.NEXT_PUBLIC_MICROSOFT_CLIENT_ID = 'microsoft-client';
|
||||
mockedApi.get.mockResolvedValue({ data: { requireAuth: true, googleEnabled: false, microsoftEnabled: true, localEnabled: true, allowRegistration: true, requireEmailVerification: true } } as any);
|
||||
mockMsalLoginPopup.mockRejectedValueOnce({ errorCode: 'user_cancelled', message: 'Microsoft sign-in was cancelled.' });
|
||||
|
||||
renderLoginPage();
|
||||
await click(await screen.findByRole('button', { name: 'Continue with Microsoft' }));
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Microsoft sign-in was cancelled.');
|
||||
expect(mockedApi.post).not.toHaveBeenCalledWith('/auth/microsoft/exchange', expect.anything());
|
||||
expect(mockNavigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('completes the Microsoft provider return through the hardened exchange endpoint', async () => {
|
||||
process.env.NEXT_PUBLIC_MICROSOFT_CLIENT_ID = 'microsoft-client';
|
||||
mockedApi.get.mockResolvedValue({ data: { requireAuth: true, googleEnabled: false, microsoftEnabled: true, localEnabled: true, allowRegistration: true, requireEmailVerification: true } } as any);
|
||||
mockMsalLoginPopup.mockResolvedValueOnce({ idToken: 'synthetic-microsoft-token' });
|
||||
mockedApi.post.mockResolvedValueOnce({ data: { authenticated: true, provider: 'microsoft' } } as any);
|
||||
|
||||
renderLoginPage('login', '/jobs');
|
||||
await click(await screen.findByRole('button', { name: 'Continue with Microsoft' }));
|
||||
|
||||
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/auth/microsoft/exchange', { token: 'synthetic-microsoft-token', rememberMe: true }));
|
||||
await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/jobs', { replace: true }));
|
||||
});
|
||||
|
||||
it('completes a Google credential return without account-linking copy', async () => {
|
||||
process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID = 'google-client';
|
||||
let callback: ((response: { credential: string }) => void) | undefined;
|
||||
(window as any).google = { accounts: { id: {
|
||||
initialize: jest.fn((options: any) => { callback = options.callback; }),
|
||||
renderButton: jest.fn((host: HTMLElement) => {
|
||||
const button = document.createElement('button');
|
||||
button.textContent = 'Continue with Google';
|
||||
button.onclick = () => callback?.({ credential: 'synthetic-google-token' });
|
||||
host.appendChild(button);
|
||||
}),
|
||||
} } };
|
||||
mockedApi.get.mockResolvedValue({ data: { requireAuth: true, googleEnabled: true, microsoftEnabled: false, localEnabled: true, allowRegistration: true, requireEmailVerification: true } } as any);
|
||||
mockedApi.post.mockResolvedValueOnce({ data: { authenticated: true, provider: 'google' } } as any);
|
||||
|
||||
renderLoginPage();
|
||||
await click(await screen.findByRole('button', { name: 'Continue with Google' }));
|
||||
|
||||
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/auth/google/exchange', { token: 'synthetic-google-token', rememberMe: true }));
|
||||
await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/dashboard', { replace: true }));
|
||||
expect(screen.queryByText(/create your account automatically/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('posts remember-me preference without storing an auth token in browser storage', async () => {
|
||||
mockedApi.post.mockResolvedValueOnce({ data: { authenticated: true, provider: 'local' } } as any);
|
||||
mockedApi.get.mockResolvedValueOnce({ data: { roles: [], email: 'person@example.com', userName: 'person' } } as any);
|
||||
|
||||
renderLoginPage();
|
||||
await screen.findByLabelText('Email');
|
||||
await screen.findByLabelText('Username or email');
|
||||
|
||||
await userEvent.type(screen.getByLabelText('Email'), 'person@example.com');
|
||||
await userEvent.type(screen.getByLabelText('Username or email'), 'person@example.com');
|
||||
await userEvent.type(screen.getByLabelText('Current password'), 'hunter2');
|
||||
await click(screen.getByLabelText('Remember me'));
|
||||
await click(screen.getByRole('button', { name: 'Sign in' }));
|
||||
@@ -113,7 +218,7 @@ describe('LoginPage', () => {
|
||||
mockedApi.get.mockResolvedValueOnce({ data: { roles: [], email: 'person@example.com', userName: 'person' } } as any);
|
||||
|
||||
renderLoginPage("login", "/\\evil.example");
|
||||
await userEvent.type(await screen.findByLabelText('Email'), 'person@example.com');
|
||||
await userEvent.type(await screen.findByLabelText('Username or email'), 'person@example.com');
|
||||
await userEvent.type(screen.getByLabelText('Current password'), 'hunter2');
|
||||
await click(screen.getByRole('button', { name: 'Sign in' }));
|
||||
|
||||
@@ -122,9 +227,9 @@ describe('LoginPage', () => {
|
||||
|
||||
it('opens the separate forgot-password page with the typed email prefilled', async () => {
|
||||
renderLoginPage();
|
||||
await screen.findByLabelText('Email');
|
||||
await screen.findByLabelText('Username or email');
|
||||
|
||||
await userEvent.type(screen.getByLabelText('Email'), 'person@example.com');
|
||||
await userEvent.type(screen.getByLabelText('Username or email'), 'person@example.com');
|
||||
await click(screen.getByRole('button', { name: 'Forgot password?' }));
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/forgot-password?email=person%40example.com');
|
||||
@@ -144,14 +249,14 @@ describe('LoginPage', () => {
|
||||
mockedApi.get.mockResolvedValueOnce({ data: { roles: [], email: 'person@example.com', userName: 'person' } } as any);
|
||||
|
||||
renderLoginPage();
|
||||
await screen.findByLabelText('Email');
|
||||
await screen.findByLabelText('Username or email');
|
||||
|
||||
await userEvent.type(screen.getByLabelText('Email'), 'person@example.com');
|
||||
await userEvent.type(screen.getByLabelText('Username or email'), 'person@example.com');
|
||||
await userEvent.type(screen.getByLabelText('Current password'), 'hunter2');
|
||||
await click(screen.getByRole('button', { name: 'Sign in' }));
|
||||
|
||||
await screen.findByText('Two-factor verification');
|
||||
expect(screen.queryByLabelText('Email')).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('Username or email')).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.type(screen.getByLabelText('Code'), '123456');
|
||||
await click(screen.getByRole('button', { name: 'Verify' }));
|
||||
@@ -173,8 +278,8 @@ describe('LoginPage', () => {
|
||||
});
|
||||
|
||||
renderLoginPage();
|
||||
await screen.findByLabelText('Email');
|
||||
await userEvent.type(screen.getByLabelText('Email'), 'person@example.com');
|
||||
await screen.findByLabelText('Username or email');
|
||||
await userEvent.type(screen.getByLabelText('Username or email'), 'person@example.com');
|
||||
await userEvent.type(screen.getByLabelText('Current password'), 'hunter2');
|
||||
await click(screen.getByRole('button', { name: 'Sign in' }));
|
||||
|
||||
@@ -200,9 +305,9 @@ describe('LoginPage', () => {
|
||||
});
|
||||
|
||||
renderLoginPage();
|
||||
await screen.findByLabelText('Email');
|
||||
await screen.findByLabelText('Username or email');
|
||||
|
||||
await userEvent.type(screen.getByLabelText('Email'), 'unverified@example.com');
|
||||
await userEvent.type(screen.getByLabelText('Username or email'), 'unverified@example.com');
|
||||
await userEvent.type(screen.getByLabelText('Current password'), 'hunter2');
|
||||
await click(screen.getByRole('button', { name: 'Sign in' }));
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import { Alert, Box, Button, Checkbox, FormControlLabel, Paper, Tab, Tabs, TextField, Typography } from "@mui/material";
|
||||
import { Alert, Box, Button, Checkbox, Divider, FormControlLabel, Paper, TextField, Typography } from "@mui/material";
|
||||
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
@@ -30,7 +30,6 @@ export default function LoginPage({ initialMode = "login" }: { initialMode?: "lo
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation() as any;
|
||||
|
||||
const [tab, setTab] = useState(0);
|
||||
const [cfg, setCfg] = useState<AuthConfig | null>(null);
|
||||
|
||||
const [email, setEmail] = useState("");
|
||||
@@ -72,8 +71,8 @@ export default function LoginPage({ initialMode = "login" }: { initialMode?: "lo
|
||||
|
||||
function validate(mode: "login" | "register") {
|
||||
const errors: { email?: string; password?: string; confirmPassword?: string } = {};
|
||||
if (!email.trim()) errors.email = t("emailRequired");
|
||||
else if (!EMAIL_PATTERN.test(email.trim())) errors.email = t("invalidEmail");
|
||||
if (!email.trim()) errors.email = mode === "login" ? t("usernameOrEmailRequired") : t("emailRequired");
|
||||
else if (mode === "register" && !EMAIL_PATTERN.test(email.trim())) errors.email = t("invalidEmail");
|
||||
|
||||
if (!password) errors.password = t("passwordRequired");
|
||||
else if (mode === "register" && password.length < 8) errors.password = t("passwordTooShort");
|
||||
@@ -159,18 +158,11 @@ export default function LoginPage({ initialMode = "login" }: { initialMode?: "lo
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{initialMode === "login" ? <Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 2 }}>
|
||||
<Tab label={t("emailAndPassword")} />
|
||||
<Tab label={t("google")} />
|
||||
<Tab label={t("microsoft")} />
|
||||
</Tabs> : null}
|
||||
|
||||
{tab === 0 && (
|
||||
<Box
|
||||
component="form"
|
||||
onSubmit={(e) => { e.preventDefault(); void submit(registerMode ? "register" : "login"); }}
|
||||
sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}
|
||||
>
|
||||
<Box
|
||||
component="form"
|
||||
onSubmit={(e) => { e.preventDefault(); void submit(registerMode ? "register" : "login"); }}
|
||||
sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}
|
||||
>
|
||||
{registerMode && cfg && !allowReg ? <Alert severity="info">Registration is currently unavailable.</Alert> : null}
|
||||
{cfg?.requireEmailVerification && emailNotVerified && (
|
||||
<Alert
|
||||
@@ -184,12 +176,12 @@ export default function LoginPage({ initialMode = "login" }: { initialMode?: "lo
|
||||
{t("emailNotVerified")}
|
||||
</Alert>
|
||||
)}
|
||||
<TextField
|
||||
label={t("profileEmail")}
|
||||
type="email"
|
||||
<TextField
|
||||
label={registerMode ? t("profileEmail") : t("usernameOrEmail")}
|
||||
type={registerMode ? "email" : "text"}
|
||||
value={email}
|
||||
onChange={(e) => { setEmail(e.target.value); setFieldErrors((f) => ({ ...f, email: undefined })); }}
|
||||
autoComplete="email"
|
||||
autoComplete={registerMode ? "email" : "username"}
|
||||
autoFocus
|
||||
error={Boolean(fieldErrors.email)}
|
||||
helperText={fieldErrors.email}
|
||||
@@ -265,11 +257,17 @@ export default function LoginPage({ initialMode = "login" }: { initialMode?: "lo
|
||||
{registerMode ? t("createAccount") : t("signInTitle")}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{tab === 1 && <GoogleAuthCard onSignedIn={() => { navigate(nextPath, { replace: true }); }} />}
|
||||
{tab === 2 && <MicrosoftAuthCard onSignedIn={() => { navigate(nextPath, { replace: true }); }} />}
|
||||
{!registerMode && (cfg?.googleEnabled || cfg?.microsoftEnabled) ? (
|
||||
<Box sx={{ mt: 2.5 }}>
|
||||
<Divider sx={{ mb: 2 }}>{t("or")}</Divider>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
|
||||
{cfg?.googleEnabled ? <GoogleAuthCard presentation="sign-in" onSignedIn={() => { navigate(nextPath, { replace: true }); }} /> : null}
|
||||
{cfg?.microsoftEnabled ? <MicrosoftAuthCard presentation="sign-in" onSignedIn={() => { navigate(nextPath, { replace: true }); }} /> : null}
|
||||
</Box>
|
||||
</Box>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
Reference in New Issue
Block a user