Polish settings and auth flows
This commit is contained in:
@@ -43,14 +43,14 @@ jobs:
|
||||
|
||||
- name: Test frontend
|
||||
working-directory: job-tracker-ui
|
||||
run: npm test -- --watchAll=false --runInBand App.test.tsx confirm.test.tsx prompt.test.tsx dialog-flow.test.tsx confirm-flow.test.tsx attachments.test.tsx job-details-generated-drafts.test.tsx admin-system-page.test.tsx profile-page.test.tsx
|
||||
run: npm test -- --watchAll=false --runInBand App.test.tsx confirm.test.tsx prompt.test.tsx dialog-flow.test.tsx confirm-flow.test.tsx attachments.test.tsx job-details-generated-drafts.test.tsx admin-system-page.test.tsx profile-page.test.tsx login-page.test.tsx
|
||||
|
||||
- name: Build frontend
|
||||
working-directory: job-tracker-ui
|
||||
env:
|
||||
CI: 'false'
|
||||
GENERATE_SOURCEMAP: 'false'
|
||||
NODE_OPTIONS: --max-old-space-size=512
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
run: npm run build
|
||||
|
||||
deploy:
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"build": "node --max-old-space-size=4096 ./node_modules/react-scripts/bin/react-scripts.js build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
|
||||
+77
-45
@@ -1,74 +1,106 @@
|
||||
export const AUTH_TOKEN_KEY = "authToken";
|
||||
export const AUTH_REMEMBER_ME_KEY = "authRememberMe";
|
||||
const LEGACY_AUTH_TOKEN_KEY = "googleIdToken";
|
||||
const AUTH_TOKEN_PERSISTENCE_KEY = "authTokenPersistence";
|
||||
|
||||
function getStoredToken(storage: Storage): string | null {
|
||||
type AuthPersistence = "local" | "session";
|
||||
|
||||
function normalizePersistence(value: string | null | undefined): AuthPersistence {
|
||||
return value === "session" ? "session" : "local";
|
||||
}
|
||||
|
||||
function safeGet(storage: Storage, key: string): string | null {
|
||||
try {
|
||||
return storage.getItem(AUTH_TOKEN_KEY);
|
||||
return storage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function safeSet(storage: Storage, key: string, value: string) {
|
||||
try {
|
||||
storage.setItem(key, value);
|
||||
} catch {
|
||||
// ignore storage failures
|
||||
}
|
||||
}
|
||||
|
||||
function safeRemove(storage: Storage, key: string) {
|
||||
try {
|
||||
storage.removeItem(key);
|
||||
} catch {
|
||||
// ignore storage failures
|
||||
}
|
||||
}
|
||||
|
||||
function persistPreference(persistence: AuthPersistence) {
|
||||
safeSet(window.localStorage, AUTH_TOKEN_PERSISTENCE_KEY, persistence);
|
||||
safeSet(window.localStorage, AUTH_REMEMBER_ME_KEY, persistence === "local" ? "1" : "0");
|
||||
}
|
||||
|
||||
function getStoredPersistence(): AuthPersistence {
|
||||
const explicit = safeGet(window.localStorage, AUTH_TOKEN_PERSISTENCE_KEY);
|
||||
if (explicit) return normalizePersistence(explicit);
|
||||
const rememberMe = safeGet(window.localStorage, AUTH_REMEMBER_ME_KEY);
|
||||
if (rememberMe === "0") return "session";
|
||||
return "local";
|
||||
}
|
||||
|
||||
function migrateLegacyToken(): string | null {
|
||||
const legacy = safeGet(window.localStorage, LEGACY_AUTH_TOKEN_KEY) ?? safeGet(window.sessionStorage, LEGACY_AUTH_TOKEN_KEY);
|
||||
if (!legacy) return null;
|
||||
safeRemove(window.localStorage, LEGACY_AUTH_TOKEN_KEY);
|
||||
safeRemove(window.sessionStorage, LEGACY_AUTH_TOKEN_KEY);
|
||||
setAuthToken(legacy, getStoredPersistence());
|
||||
return legacy;
|
||||
}
|
||||
|
||||
export function getRememberMePref(): boolean {
|
||||
try {
|
||||
return window.localStorage.getItem(AUTH_REMEMBER_ME_KEY) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return getAuthPersistencePreference() === "local";
|
||||
}
|
||||
|
||||
export function setRememberMePref(value: boolean) {
|
||||
try {
|
||||
window.localStorage.setItem(AUTH_REMEMBER_ME_KEY, value ? "1" : "0");
|
||||
} catch {
|
||||
// ignore storage failures
|
||||
}
|
||||
persistPreference(value ? "local" : "session");
|
||||
}
|
||||
|
||||
export function getAuthToken(): string | null {
|
||||
const current = getStoredToken(window.localStorage) || getStoredToken(window.sessionStorage);
|
||||
if (current) return current;
|
||||
|
||||
// Backward compat for older builds that stored Google ID tokens under a different key.
|
||||
const legacy = window.localStorage.getItem(LEGACY_AUTH_TOKEN_KEY) || window.sessionStorage.getItem(LEGACY_AUTH_TOKEN_KEY);
|
||||
if (legacy) {
|
||||
const remember = getRememberMePref();
|
||||
setAuthToken(legacy, { remember });
|
||||
try {
|
||||
window.localStorage.removeItem(LEGACY_AUTH_TOKEN_KEY);
|
||||
window.sessionStorage.removeItem(LEGACY_AUTH_TOKEN_KEY);
|
||||
} catch {
|
||||
// ignore storage failures
|
||||
}
|
||||
return legacy;
|
||||
const localToken = safeGet(window.localStorage, AUTH_TOKEN_KEY);
|
||||
if (localToken) {
|
||||
persistPreference("local");
|
||||
return localToken;
|
||||
}
|
||||
|
||||
return null;
|
||||
const sessionToken = safeGet(window.sessionStorage, AUTH_TOKEN_KEY);
|
||||
if (sessionToken) {
|
||||
persistPreference("session");
|
||||
return sessionToken;
|
||||
}
|
||||
|
||||
return migrateLegacyToken();
|
||||
}
|
||||
|
||||
export function setAuthToken(token: string, options?: { remember?: boolean }) {
|
||||
const remember = options?.remember ?? getRememberMePref();
|
||||
try {
|
||||
if (remember) {
|
||||
window.localStorage.setItem(AUTH_TOKEN_KEY, token);
|
||||
window.sessionStorage.removeItem(AUTH_TOKEN_KEY);
|
||||
export function getAuthPersistencePreference(): AuthPersistence {
|
||||
if (safeGet(window.sessionStorage, AUTH_TOKEN_KEY)) return "session";
|
||||
if (safeGet(window.localStorage, AUTH_TOKEN_KEY)) return "local";
|
||||
return getStoredPersistence();
|
||||
}
|
||||
|
||||
export function setAuthToken(token: string, persistence: AuthPersistence = "local") {
|
||||
safeRemove(window.localStorage, AUTH_TOKEN_KEY);
|
||||
safeRemove(window.sessionStorage, AUTH_TOKEN_KEY);
|
||||
|
||||
if (persistence === "session") {
|
||||
safeSet(window.sessionStorage, AUTH_TOKEN_KEY, token);
|
||||
} else {
|
||||
window.sessionStorage.setItem(AUTH_TOKEN_KEY, token);
|
||||
window.localStorage.removeItem(AUTH_TOKEN_KEY);
|
||||
}
|
||||
} catch {
|
||||
window.localStorage.setItem(AUTH_TOKEN_KEY, token);
|
||||
safeSet(window.localStorage, AUTH_TOKEN_KEY, token);
|
||||
}
|
||||
|
||||
persistPreference(persistence);
|
||||
}
|
||||
|
||||
export function clearAuthToken() {
|
||||
try {
|
||||
window.localStorage.removeItem(AUTH_TOKEN_KEY);
|
||||
window.sessionStorage.removeItem(AUTH_TOKEN_KEY);
|
||||
} catch {
|
||||
// ignore storage failures
|
||||
}
|
||||
safeRemove(window.localStorage, AUTH_TOKEN_KEY);
|
||||
safeRemove(window.sessionStorage, AUTH_TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function decodeJwtPayload(token: string): any {
|
||||
|
||||
@@ -62,6 +62,8 @@ type AttachmentBuckets = Record<AttachmentBucketKey, File[]>;
|
||||
|
||||
const STATUS_OPTIONS = ["Applied", "Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const;
|
||||
const ACCEPTED_DOCUMENT_TYPES = ".pdf,.doc,.docx,.txt,.md,image/*,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,text/plain,text/markdown";
|
||||
const FIELD_SX = { "& .MuiInputBase-root": { minHeight: 56 } };
|
||||
const PICKER_TEXT_FIELD_PROPS = { fullWidth: true, sx: FIELD_SX };
|
||||
|
||||
function getTodayIso() {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
@@ -454,7 +456,7 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2, mt: 1 }}>
|
||||
<TextField label={t("addJobModalJobUrl")} value={jobUrl} onChange={(e) => setJobUrl(e.target.value)} sx={{ gridColumn: "1 / -1" }} />
|
||||
<TextField label={t("addJobModalJobUrl")} value={jobUrl} onChange={(e) => setJobUrl(e.target.value)} sx={{ ...FIELD_SX, gridColumn: "1 / -1" }} />
|
||||
<Box sx={{ gridColumn: "1 / -1", display: "flex", justifyContent: "flex-end" }}>
|
||||
<Button onClick={() => void importFromUrl()} disabled={importing || !jobUrl.trim()}>
|
||||
{importing ? t("addJobModalImporting") : t("addJobModalImportFromUrl")}
|
||||
@@ -465,10 +467,10 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
|
||||
label={t("addJobModalDateApplied")}
|
||||
value={parsePickerDate(dateApplied)}
|
||||
onChange={(value) => setDateApplied(toPickerIso(value))}
|
||||
slotProps={{ textField: { fullWidth: true } }}
|
||||
slotProps={{ textField: PICKER_TEXT_FIELD_PROPS }}
|
||||
/>
|
||||
|
||||
<TextField select label={t("addJobModalStatus")} value={status} onChange={(e) => setStatus(e.target.value as any)}>
|
||||
<TextField select label={t("addJobModalStatus")} value={status} onChange={(e) => setStatus(e.target.value as any)} sx={FIELD_SX}>
|
||||
{STATUS_OPTIONS.map((s) => (
|
||||
<MenuItem key={s} value={s}>
|
||||
{statusLabel(s)}
|
||||
@@ -476,15 +478,15 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
|
||||
))}
|
||||
</TextField>
|
||||
|
||||
<TextField label={t("addJobModalJobTitle")} value={jobTitle} onChange={(e) => setJobTitle(e.target.value)} />
|
||||
<TextField label={t("location")} value={location} onChange={(e) => setLocation(e.target.value)} />
|
||||
<TextField label={t("addJobModalJobTitle")} value={jobTitle} onChange={(e) => setJobTitle(e.target.value)} sx={FIELD_SX} />
|
||||
<TextField label={t("location")} value={location} onChange={(e) => setLocation(e.target.value)} sx={FIELD_SX} />
|
||||
|
||||
<TextField label={t("addJobModalSalary")} value={salary} onChange={(e) => setSalary(e.target.value)} />
|
||||
<TextField label={t("addJobModalSalary")} value={salary} onChange={(e) => setSalary(e.target.value)} sx={FIELD_SX} />
|
||||
<DatePicker
|
||||
label={t("addJobModalDeadline")}
|
||||
value={parsePickerDate(deadline)}
|
||||
onChange={(value) => setDeadline(toPickerIso(value))}
|
||||
slotProps={{ textField: { fullWidth: true } }}
|
||||
slotProps={{ textField: PICKER_TEXT_FIELD_PROPS }}
|
||||
/>
|
||||
|
||||
<Box sx={{ gridColumn: "1 / -1" }}>
|
||||
|
||||
@@ -33,6 +33,8 @@ interface Props {
|
||||
}
|
||||
|
||||
const STATUS_OPTIONS = ["Applied", "Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const;
|
||||
const FIELD_SX = { "& .MuiInputBase-root": { minHeight: 56 } };
|
||||
const PICKER_TEXT_FIELD_PROPS = { fullWidth: true, sx: FIELD_SX };
|
||||
|
||||
function toDateInputValue(isoLike?: string): string {
|
||||
if (!isoLike) return new Date().toISOString().slice(0, 10);
|
||||
@@ -182,34 +184,34 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
|
||||
<Paper variant="outlined" sx={{ p: 2 }}>
|
||||
<Typography variant="overline" sx={{ color: "text.secondary" }}>{t("editJobApplicationDetails")}</Typography>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2, mt: 1 }}>
|
||||
<Autocomplete options={companies} getOptionLabel={(c) => c.name} value={company} onChange={(_, v) => setCompany(v)} renderInput={(params) => <TextField {...params} label={t("company")} />} />
|
||||
<TextField label={t("editJobJobTitle")} value={jobTitle} onChange={(e) => setJobTitle(e.target.value)} />
|
||||
<DatePicker label={t("editJobAppliedOn")} value={parsePickerDate(dateApplied)} onChange={(value) => setDateApplied(toPickerIso(value))} slotProps={{ textField: { fullWidth: true } }} />
|
||||
<TextField label={t("addJobModalJobUrl")} value={jobUrl} onChange={(e) => setJobUrl(e.target.value)} />
|
||||
<Autocomplete options={companies} getOptionLabel={(c) => c.name} value={company} onChange={(_, v) => setCompany(v)} renderInput={(params) => <TextField {...params} label={t("company")} sx={FIELD_SX} />} />
|
||||
<TextField label={t("editJobJobTitle")} value={jobTitle} onChange={(e) => setJobTitle(e.target.value)} sx={FIELD_SX} />
|
||||
<DatePicker label={t("editJobAppliedOn")} value={parsePickerDate(dateApplied)} onChange={(value) => setDateApplied(toPickerIso(value))} slotProps={{ textField: PICKER_TEXT_FIELD_PROPS }} />
|
||||
<TextField label={t("addJobModalJobUrl")} value={jobUrl} onChange={(e) => setJobUrl(e.target.value)} sx={FIELD_SX} />
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<Paper variant="outlined" sx={{ p: 2 }}>
|
||||
<Typography variant="overline" sx={{ color: "text.secondary" }}>{t("editJobStatusUpdate")}</Typography>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr 1fr" }, gap: 2, mt: 1 }}>
|
||||
<TextField select label={t("editJobCurrentStatus")} value={status} onChange={(e) => setStatus(e.target.value)}>
|
||||
<TextField select label={t("editJobCurrentStatus")} value={status} onChange={(e) => setStatus(e.target.value)} sx={FIELD_SX}>
|
||||
{STATUS_OPTIONS.map((s) => <MenuItem key={s} value={s}>{s}</MenuItem>)}
|
||||
</TextField>
|
||||
<DatePicker label={t("editJobStatusChangedOn")} value={parsePickerDate(statusChangedAt)} onChange={(value) => setStatusChangedAt(toPickerIso(value))} slotProps={{ textField: { fullWidth: true, helperText: status === initialStatus ? t("editJobStatusChangedHelpIdle") : t("editJobStatusChangedHelpActive") } }} />
|
||||
<DatePicker label={t("editJobStatusChangedOn")} value={parsePickerDate(statusChangedAt)} onChange={(value) => setStatusChangedAt(toPickerIso(value))} slotProps={{ textField: { ...PICKER_TEXT_FIELD_PROPS, helperText: status === initialStatus ? t("editJobStatusChangedHelpIdle") : t("editJobStatusChangedHelpActive") } }} />
|
||||
<Box sx={{ display: "flex", alignItems: "center" }}><FormControlLabel control={<Checkbox checked={responseReceived} onChange={(e) => setResponseReceived(e.target.checked)} />} label={t("editJobReplyReceived")} /></Box>
|
||||
<DatePicker label={t("editJobReplyReceivedOn")} disabled={!responseReceived} value={parsePickerDate(responseDate)} onChange={(value) => setResponseDate(toPickerIso(value))} slotProps={{ textField: { fullWidth: true } }} />
|
||||
<TextField label={t("editJobNextAction")} value={nextAction} onChange={(e) => setNextAction(e.target.value)} />
|
||||
<DatePicker label={t("editJobFollowUpOn")} value={parsePickerDate(followUpAt)} onChange={(value) => setFollowUpAt(toPickerIso(value))} slotProps={{ textField: { fullWidth: true } }} />
|
||||
<DatePicker label={t("editJobReplyReceivedOn")} disabled={!responseReceived} value={parsePickerDate(responseDate)} onChange={(value) => setResponseDate(toPickerIso(value))} slotProps={{ textField: PICKER_TEXT_FIELD_PROPS }} />
|
||||
<TextField label={t("editJobNextAction")} value={nextAction} onChange={(e) => setNextAction(e.target.value)} sx={FIELD_SX} />
|
||||
<DatePicker label={t("editJobFollowUpOn")} value={parsePickerDate(followUpAt)} onChange={(value) => setFollowUpAt(toPickerIso(value))} slotProps={{ textField: PICKER_TEXT_FIELD_PROPS }} />
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<Paper variant="outlined" sx={{ p: 2 }}>
|
||||
<Typography variant="overline" sx={{ color: "text.secondary" }}>{t("editJobRoleDetails")}</Typography>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2, mt: 1 }}>
|
||||
<TextField label={t("location")} value={location} onChange={(e) => setLocation(e.target.value)} />
|
||||
<TextField label={t("addJobModalSalary")} value={salary} onChange={(e) => setSalary(e.target.value)} />
|
||||
<DatePicker label={t("editJobDeadline")} value={parsePickerDate(deadline)} onChange={(value) => setDeadline(toPickerIso(value))} slotProps={{ textField: { fullWidth: true } }} />
|
||||
<TextField label={t("editJobDescriptionLanguage")} value={descriptionLanguage} onChange={(e) => setDescriptionLanguage(e.target.value)} />
|
||||
<TextField label={t("location")} value={location} onChange={(e) => setLocation(e.target.value)} sx={FIELD_SX} />
|
||||
<TextField label={t("addJobModalSalary")} value={salary} onChange={(e) => setSalary(e.target.value)} sx={FIELD_SX} />
|
||||
<DatePicker label={t("editJobDeadline")} value={parsePickerDate(deadline)} onChange={(value) => setDeadline(toPickerIso(value))} slotProps={{ textField: PICKER_TEXT_FIELD_PROPS }} />
|
||||
<TextField label={t("editJobDescriptionLanguage")} value={descriptionLanguage} onChange={(e) => setDescriptionLanguage(e.target.value)} sx={FIELD_SX} />
|
||||
<Box sx={{ gridColumn: "1 / -1" }}><TagsInput value={tags} onChange={setTags} /></Box>
|
||||
<TextField label={t("editJobNotes")} value={notes} onChange={(e) => setNotes(e.target.value)} multiline rows={4} helperText={t("correspondenceCharacters", { count: notes.length })} sx={{ gridColumn: "1 / -1" }} />
|
||||
<TextField label={t("editJobDescriptionOriginal")} value={description} onChange={(e) => setDescription(e.target.value)} multiline rows={6} helperText={t("correspondenceCharacters", { count: description.length })} sx={{ gridColumn: "1 / -1" }} />
|
||||
|
||||
@@ -3,7 +3,7 @@ import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Box, Button, Chip, Paper, Typography } from "@mui/material";
|
||||
|
||||
import { api } from "../api";
|
||||
import { clearAuthToken, decodeJwtPayload, getAuthToken, setAuthToken } from "../auth";
|
||||
import { clearAuthToken, decodeJwtPayload, getAuthPersistencePreference, getAuthToken, setAuthToken } from "../auth";
|
||||
import { useToast } from "../toast";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
|
||||
@@ -58,6 +58,12 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void
|
||||
const payload = useMemo(() => (token ? decodeJwtPayload(token) : null), [token]);
|
||||
const isRawGoogleToken = payload?.iss === "accounts.google.com" || payload?.iss === "https://accounts.google.com";
|
||||
|
||||
const actionLabel = !token
|
||||
? t("continueWithGoogle")
|
||||
: me?.provider === "local" && !me?.googleLink?.linked
|
||||
? t("linkWithGoogle")
|
||||
: t("signInWithGoogle");
|
||||
|
||||
async function refreshMe() {
|
||||
if (!getAuthToken()) {
|
||||
setMe(null);
|
||||
@@ -82,7 +88,7 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void
|
||||
try {
|
||||
const res = await api.post<{ accessToken: string }>("/auth/google/exchange", { token });
|
||||
if (cancelled) return;
|
||||
setAuthToken(res.data.accessToken);
|
||||
setAuthToken(res.data.accessToken, getAuthPersistencePreference());
|
||||
setToken(res.data.accessToken);
|
||||
toast(t("googleSignedIn"), "success");
|
||||
onSignedIn?.();
|
||||
@@ -125,7 +131,7 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void
|
||||
await refreshMe();
|
||||
} else {
|
||||
const res = await api.post<{ accessToken: string }>("/auth/google/exchange", { token: credential });
|
||||
setAuthToken(res.data.accessToken);
|
||||
setAuthToken(res.data.accessToken, getAuthPersistencePreference());
|
||||
setToken(res.data.accessToken);
|
||||
toast(t("googleSignedIn"), "success");
|
||||
onSignedIn?.();
|
||||
@@ -191,9 +197,14 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 2, flexWrap: "wrap" }}>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", alignItems: "flex-start", gap: 1 }}>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary", fontWeight: 700, letterSpacing: 0.4, textTransform: "uppercase" }}>
|
||||
{actionLabel}
|
||||
</Typography>
|
||||
<div ref={hostRef} />
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 2, flexWrap: "wrap" }}>
|
||||
{token ? (
|
||||
<Button
|
||||
variant="outlined"
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { JobTableColumns } from "./JobTable";
|
||||
import ImportExportJobs from "./ImportExportJobs";
|
||||
import GoogleAuthCard from "./GoogleAuthCard";
|
||||
@@ -54,6 +56,7 @@ export default function SettingsView({
|
||||
onAccentColorChange,
|
||||
onResetAccentColor,
|
||||
}: Props) {
|
||||
const navigate = useNavigate();
|
||||
const [tab, setTab] = useState(0);
|
||||
const { language, setLanguage, t } = useI18n();
|
||||
|
||||
@@ -96,15 +99,14 @@ export default function SettingsView({
|
||||
</FormControl>
|
||||
|
||||
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 2, flexWrap: "wrap" }}>
|
||||
<input type="hidden" />
|
||||
<FormControl sx={{ width: 160 }}>
|
||||
<Typography variant="caption" sx={{ mb: 0.5 }}>{t("settingsAccent")}</Typography>
|
||||
<Typography variant="caption" sx={{ mb: 0.75 }}>{t("settingsAccent")}</Typography>
|
||||
<input
|
||||
aria-label={t("settingsAccent")}
|
||||
type="color"
|
||||
value={accentOk ? accentColor : "#15803d"}
|
||||
onChange={(e) => onAccentColorChange(e.target.value)}
|
||||
style={{ width: 160, height: 40, border: "none", background: "transparent", padding: 0 }}
|
||||
style={{ width: 160, height: 56, border: "none", background: "transparent", padding: 0 }}
|
||||
/>
|
||||
</FormControl>
|
||||
<Button variant="outlined" onClick={onResetAccentColor}>
|
||||
@@ -119,6 +121,7 @@ export default function SettingsView({
|
||||
type="button"
|
||||
onClick={() => onAccentColorChange(c)}
|
||||
title={c}
|
||||
aria-label={`${t("settingsAccent")} ${c}`}
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
@@ -197,12 +200,7 @@ export default function SettingsView({
|
||||
).map(([key, label]) => (
|
||||
<FormControlLabel
|
||||
key={key}
|
||||
control={
|
||||
<Checkbox
|
||||
checked={columns[key]}
|
||||
onChange={() => onColumnsChange({ ...columns, [key]: !columns[key] })}
|
||||
/>
|
||||
}
|
||||
control={<Checkbox checked={columns[key]} onChange={() => onColumnsChange({ ...columns, [key]: !columns[key] })} />}
|
||||
label={label}
|
||||
/>
|
||||
))}
|
||||
@@ -215,31 +213,36 @@ export default function SettingsView({
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={tab} index={1}>
|
||||
<Box sx={{ display: "grid", gap: 2 }}>
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Typography sx={{ fontWeight: 950, mb: 0.5 }}>{t("settingsFollowUpsTitle")}</Typography>
|
||||
<Typography sx={{ color: "text.secondary", mb: 1.5 }}>{t("settingsFollowUpsBody")}</Typography>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
||||
<Button variant="outlined" onClick={() => navigate("/reminders")}>{t("settingsOpenReminderInbox")}</Button>
|
||||
<Button variant="text" onClick={() => navigate("/jobs")}>{t("settingsReviewJobs")}</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
<RulesSettingsCard />
|
||||
</Box>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={tab} index={2}>
|
||||
<Paper sx={{ p: 2, mt: 2 }}>
|
||||
<Typography sx={{ fontWeight: 950, mb: 0.5 }}>{t("settingsNotificationsTitle")}</Typography>
|
||||
<Typography sx={{ color: "text.secondary", mb: 2 }}>
|
||||
{t("settingsNotificationsBody")}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
|
||||
<Paper sx={{ p: 2, borderRadius: 3 }}>
|
||||
<Typography sx={{ fontWeight: 800, mb: 0.5 }}>{t("settingsNotificationsFollowUpsTitle")}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("settingsNotificationsFollowUpsBody")}</Typography>
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Typography sx={{ fontWeight: 950, mb: 0.5 }}>{t("settingsNotificationsTitle")}</Typography>
|
||||
<Typography sx={{ color: "text.secondary", mb: 1.5 }}>{t("settingsNotificationsBody")}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("settingsNotificationsDelivery")}</Typography>
|
||||
</Paper>
|
||||
<Paper sx={{ p: 2, borderRadius: 3 }}>
|
||||
<Typography sx={{ fontWeight: 800, mb: 0.5 }}>{t("settingsNotificationsAccountTitle")}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("settingsNotificationsAccountBody")}</Typography>
|
||||
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Typography sx={{ fontWeight: 950, mb: 0.5 }}>{t("settingsNotificationsWhatYouGetTitle")}</Typography>
|
||||
<Typography sx={{ color: "text.secondary", mb: 1.5 }}>{t("settingsNotificationsWhatYouGetBody")}</Typography>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
||||
<Button variant="outlined" onClick={() => navigate("/reminders")}>{t("settingsOpenReminderInbox")}</Button>
|
||||
<Button variant="text" onClick={() => navigate("/admin/system")}>{t("settingsCheckSystemStatus")}</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: 2 }}>
|
||||
{t("settingsNotificationsDeliveryNote")}
|
||||
</Typography>
|
||||
</Paper>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={tab} index={3}>
|
||||
|
||||
@@ -145,13 +145,16 @@ export const translations = {
|
||||
settingsColumnDateApplied: "Date applied",
|
||||
settingsColumnDays: "Days",
|
||||
settingsColumnJobUrl: "Job URL",
|
||||
settingsFollowUpsTitle: "Follow-up rules",
|
||||
settingsFollowUpsBody: "Set when applied, offer, and feedback stages should surface follow-up work or be treated as ghosted. These rules drive the reminder inbox and the job table flags.",
|
||||
settingsOpenReminderInbox: "Open reminders",
|
||||
settingsReviewJobs: "Review jobs",
|
||||
settingsNotificationsTitle: "Email notifications",
|
||||
settingsNotificationsBody: "Choose how follow-up and account emails are delivered.",
|
||||
settingsNotificationsFollowUpsTitle: "Follow-up reminders",
|
||||
settingsNotificationsFollowUpsBody: "Reminder and ghosting emails use the server SMTP configuration. Delivery follows the timing rules from the Follow-ups tab.",
|
||||
settingsNotificationsAccountTitle: "Account and security emails",
|
||||
settingsNotificationsAccountBody: "Password resets and other account notices are also sent from the system mailer so delivery stays reliable even if no personal mailbox is linked.",
|
||||
settingsNotificationsDeliveryNote: "Per-user mailboxes are not selectable yet; the current behavior is one system sender for notifications and reset flows.",
|
||||
settingsNotificationsBody: "Notifications are sent via SMTP. Configure the API with `Email:*` settings or env vars like `EMAIL_SMTP_HOST`, then Jobbjakt can deliver password reset and reminder emails.",
|
||||
settingsNotificationsDelivery: "Use the system status page to confirm SMTP is configured before testing outbound email.",
|
||||
settingsNotificationsWhatYouGetTitle: "What gets sent",
|
||||
settingsNotificationsWhatYouGetBody: "Right now the app sends password reset mail and can surface reminder-driven follow-up workflows. Gmail OAuth stays separate from SMTP delivery.",
|
||||
settingsCheckSystemStatus: "Check system status",
|
||||
profileTitle: "Profile",
|
||||
profileHeadlinePlaceholder: "Add a short headline to personalize your account view.",
|
||||
profileLocalAccount: "Local account",
|
||||
@@ -522,6 +525,9 @@ export const translations = {
|
||||
googleAvailableToLink: "Available to link",
|
||||
googleLinkedDate: "Linked {date}",
|
||||
googleSignInHint: "Sign in with a Google account that has already been linked to your Jobbjakt user.",
|
||||
continueWithGoogle: "Continue with Google",
|
||||
signInWithGoogle: "Sign in with Google",
|
||||
linkWithGoogle: "Link with Google",
|
||||
googleLinkedTo: "Linked to {email}.",
|
||||
googleLinkedToYourAccount: "Linked to your Google account.",
|
||||
googleBindHint: "Bind a Google account to this user so you can sign in with Google and still keep your normal app roles and data.",
|
||||
@@ -571,13 +577,15 @@ export const translations = {
|
||||
google: "Google",
|
||||
createAccount: "Create account",
|
||||
signedIn: "Signed in.",
|
||||
loginFailed: "Login failed.",
|
||||
rememberMe: "Remember me on this device",
|
||||
rememberMe: "Remember me",
|
||||
rememberMeHelpPersistent: "Keeps you signed in on this device until you sign out.",
|
||||
rememberMeHelpSession: "Keeps you signed in only for this browser session.",
|
||||
forgotPassword: "Forgot password?",
|
||||
loginResetEmailRequired: "Enter your email first so we know where to send the reset link.",
|
||||
loginRequestingReset: "Sending reset link…",
|
||||
loginResetRequested: "If that account exists, a reset link has been sent.",
|
||||
loginResetRequestFailed: "Could not request a password reset.",
|
||||
passwordResetEnterEmail: "Enter your email first, then request a reset link.",
|
||||
passwordResetRequestSending: "Sending reset link...",
|
||||
passwordResetRequestSent: "If that account exists, a reset link has been sent.",
|
||||
passwordResetRequestFailed: "Could not send the reset link.",
|
||||
loginFailed: "Login failed.",
|
||||
resetPasswordTitle: "Reset password",
|
||||
resetPasswordBody: "Set a new password for your account.",
|
||||
missingResetLinkInfo: "Missing email/token in link.",
|
||||
@@ -969,13 +977,16 @@ export const translations = {
|
||||
settingsColumnDateApplied: "Søkt dato",
|
||||
settingsColumnDays: "Dager",
|
||||
settingsColumnJobUrl: "Jobb-URL",
|
||||
settingsFollowUpsTitle: "Regler for oppfølging",
|
||||
settingsFollowUpsBody: "Velg når søknader, tilbud og tilbakemeldinger skal dukke opp som oppfølgingsarbeid eller regnes som ghostet. Disse reglene styrer påminnelsesinnboksen og flaggene i jobblisten.",
|
||||
settingsOpenReminderInbox: "Åpne påminnelser",
|
||||
settingsReviewJobs: "Gå til jobber",
|
||||
settingsNotificationsTitle: "E-postvarsler",
|
||||
settingsNotificationsBody: "Velg hvordan oppfølgings- og kontovarsler leveres.",
|
||||
settingsNotificationsFollowUpsTitle: "Oppfølgingspåminnelser",
|
||||
settingsNotificationsFollowUpsBody: "Påminnelser og ghosting-e-poster bruker serverens SMTP-oppsett. Leveringen følger tidsreglene på fanen Oppfølging.",
|
||||
settingsNotificationsAccountTitle: "Konto- og sikkerhetsmailer",
|
||||
settingsNotificationsAccountBody: "Tilbakestilling av passord og andre kontovarsler sendes også fra systemets avsender, slik at leveringen er stabil selv uten en personlig postkasse koblet til.",
|
||||
settingsNotificationsDeliveryNote: "Per-bruker avsendere kan ikke velges ennå; i dag brukes én systemavsender for varsler og tilbakestilling av passord.",
|
||||
settingsNotificationsBody: "Varsler sendes via SMTP. Konfigurer API-et med `Email:*`-innstillinger eller miljøvariabler som `EMAIL_SMTP_HOST`, så kan Jobbjakt sende passord-nullstilling og påminnelsesepost.",
|
||||
settingsNotificationsDelivery: "Bruk siden for systemstatus for å bekrefte at SMTP er konfigurert før du tester utgående e-post.",
|
||||
settingsNotificationsWhatYouGetTitle: "Hva som sendes",
|
||||
settingsNotificationsWhatYouGetBody: "Akkurat nå sender appen passord-nullstilling og kan støtte oppfølgingsflyt drevet av påminnelser. Gmail OAuth er fortsatt separat fra SMTP-levering.",
|
||||
settingsCheckSystemStatus: "Sjekk systemstatus",
|
||||
profileTitle: "Profil",
|
||||
profileHeadlinePlaceholder: "Legg til en kort overskrift for å gjøre kontovisningen mer personlig.",
|
||||
profileLocalAccount: "Lokal konto",
|
||||
@@ -1346,6 +1357,9 @@ export const translations = {
|
||||
googleAvailableToLink: "Tilgjengelig for kobling",
|
||||
googleLinkedDate: "Koblet {date}",
|
||||
googleSignInHint: "Logg inn med en Google-konto som allerede er koblet til Jobbjakt-brukeren din.",
|
||||
continueWithGoogle: "Fortsett med Google",
|
||||
signInWithGoogle: "Logg inn med Google",
|
||||
linkWithGoogle: "Koble til med Google",
|
||||
googleLinkedTo: "Koblet til {email}.",
|
||||
googleLinkedToYourAccount: "Koblet til Google-kontoen din.",
|
||||
googleBindHint: "Koble en Google-konto til denne brukeren slik at du kan logge inn med Google og fortsatt beholde vanlige approller og data.",
|
||||
@@ -1395,13 +1409,15 @@ export const translations = {
|
||||
google: "Google",
|
||||
createAccount: "Opprett konto",
|
||||
signedIn: "Logget inn.",
|
||||
loginFailed: "Innlogging mislyktes.",
|
||||
rememberMe: "Husk meg på denne enheten",
|
||||
rememberMe: "Husk meg",
|
||||
rememberMeHelpPersistent: "Holder deg innlogget på denne enheten til du logger ut.",
|
||||
rememberMeHelpSession: "Holder deg innlogget bare i denne nettleserøkten.",
|
||||
forgotPassword: "Glemt passord?",
|
||||
loginResetEmailRequired: "Skriv inn e-postadressen først, så vet vi hvor vi skal sende lenken.",
|
||||
loginRequestingReset: "Sender tilbakestillingslenke…",
|
||||
loginResetRequested: "Hvis kontoen finnes, er en tilbakestillingslenke sendt.",
|
||||
loginResetRequestFailed: "Kunne ikke be om tilbakestilling av passord.",
|
||||
passwordResetEnterEmail: "Skriv inn e-post først, og be deretter om en nullstillingslenke.",
|
||||
passwordResetRequestSending: "Sender nullstillingslenke...",
|
||||
passwordResetRequestSent: "Hvis kontoen finnes, er en nullstillingslenke sendt.",
|
||||
passwordResetRequestFailed: "Kunne ikke sende nullstillingslenken.",
|
||||
loginFailed: "Innlogging mislyktes.",
|
||||
resetPasswordTitle: "Tilbakestill passord",
|
||||
resetPasswordBody: "Sett et nytt passord for kontoen din.",
|
||||
missingResetLinkInfo: "Mangler e-post/token i lenken.",
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import React from 'react';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import { ToastProvider } from './toast';
|
||||
import { I18nProvider } from './i18n/I18nProvider';
|
||||
import { api } from './api';
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useNavigate: () => jest.fn(),
|
||||
}));
|
||||
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
|
||||
let consoleErrorSpy: jest.SpyInstance;
|
||||
|
||||
function renderLoginPage() {
|
||||
return render(
|
||||
<MemoryRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||
<I18nProvider>
|
||||
<ToastProvider>
|
||||
<LoginPage />
|
||||
</ToastProvider>
|
||||
</I18nProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('LoginPage', () => {
|
||||
beforeEach(() => {
|
||||
const originalConsoleError = console.error.bind(console);
|
||||
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation((...args: unknown[]) => {
|
||||
const [first] = args;
|
||||
const message = typeof first === 'string' ? first : '';
|
||||
if (message.includes('ForwardRef(TouchRipple) inside a test was not wrapped in act')) {
|
||||
return;
|
||||
}
|
||||
originalConsoleError(...args);
|
||||
});
|
||||
window.localStorage.clear();
|
||||
window.sessionStorage.clear();
|
||||
mockedApi.post.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('stores auth token in session storage when remember me is unchecked', async () => {
|
||||
mockedApi.post.mockResolvedValueOnce({ data: { accessToken: 'header.payload.sig', tokenType: 'Bearer' } } as any);
|
||||
|
||||
renderLoginPage();
|
||||
await screen.findByLabelText('Email');
|
||||
|
||||
await userEvent.type(screen.getByLabelText('Email'), 'person@example.com');
|
||||
await userEvent.type(screen.getByLabelText('Current password'), 'hunter2');
|
||||
await userEvent.click(screen.getByLabelText('Remember me'));
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Sign in' }));
|
||||
|
||||
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/auth/login', { email: 'person@example.com', password: 'hunter2' }));
|
||||
|
||||
expect(window.sessionStorage.getItem('authToken')).toBe('header.payload.sig');
|
||||
expect(window.localStorage.getItem('authToken')).toBeNull();
|
||||
expect(window.localStorage.getItem('authTokenPersistence')).toBe('session');
|
||||
});
|
||||
|
||||
it('requests a password reset link for the entered email', async () => {
|
||||
mockedApi.post.mockResolvedValueOnce({ data: {} } as any);
|
||||
|
||||
renderLoginPage();
|
||||
await screen.findByLabelText('Email');
|
||||
|
||||
await userEvent.type(screen.getByLabelText('Email'), 'person@example.com');
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Forgot password?' }));
|
||||
|
||||
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/auth/request-password-reset', { email: 'person@example.com' }));
|
||||
expect(await screen.findByText('If that account exists, a reset link has been sent.')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,11 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { Box, Button, Checkbox, FormControlLabel, Paper, Tab, Tabs, TextField, Typography } from "@mui/material";
|
||||
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
import { api } from "../api";
|
||||
import { setAuthToken, setRememberMePref, getRememberMePref } from "../auth";
|
||||
import { api, getApiErrorMessage } from "../api";
|
||||
import { getRememberMePref, setAuthToken, setRememberMePref } from "../auth";
|
||||
import GoogleAuthCard from "../components/GoogleAuthCard";
|
||||
import { useToast } from "../toast";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
@@ -28,11 +28,12 @@ export default function LoginPage() {
|
||||
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [rememberMe, setRememberMe] = useState(() => getRememberMePref());
|
||||
const [requestingReset, setRequestingReset] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [resetLoading, setResetLoading] = useState(false);
|
||||
|
||||
const nextPath = (location?.state?.from as string | undefined) ?? "/jobs";
|
||||
const canRequestPasswordReset = useMemo(() => email.trim().length > 0, [email]);
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
@@ -47,32 +48,30 @@ export default function LoginPage() {
|
||||
const url = mode === "register" ? "/auth/register" : "/auth/login";
|
||||
const res = await api.post<{ accessToken: string; tokenType: string }>(url, { email, password });
|
||||
setRememberMePref(rememberMe);
|
||||
setAuthToken(res.data.accessToken, { remember: rememberMe });
|
||||
setAuthToken(res.data.accessToken, rememberMe ? "local" : "session");
|
||||
toast(t("signedIn"), "success");
|
||||
navigate(nextPath, { replace: true });
|
||||
} catch (e: any) {
|
||||
const msg = e?.response?.data || e?.message || t("loginFailed");
|
||||
toast(String(msg), "error");
|
||||
toast(getApiErrorMessage(e, t("loginFailed")), "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function requestPasswordReset() {
|
||||
if (!email.trim()) {
|
||||
toast(t("loginResetEmailRequired"), "error");
|
||||
if (!canRequestPasswordReset) {
|
||||
toast(t("passwordResetEnterEmail"), "info");
|
||||
return;
|
||||
}
|
||||
|
||||
setRequestingReset(true);
|
||||
setResetLoading(true);
|
||||
try {
|
||||
await api.post("/auth/request-password-reset", { email: email.trim() });
|
||||
toast(t("loginResetRequested"), "success");
|
||||
toast(t("passwordResetRequestSent"), "success");
|
||||
} catch (e: any) {
|
||||
const msg = e?.response?.data || e?.message || t("loginResetRequestFailed");
|
||||
toast(String(msg), "error");
|
||||
toast(getApiErrorMessage(e, t("passwordResetRequestFailed")), "error");
|
||||
} finally {
|
||||
setRequestingReset(false);
|
||||
setResetLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,16 +106,29 @@ export default function LoginPage() {
|
||||
<Box component="form" onSubmit={(e) => { e.preventDefault(); void submit("login"); }} sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
|
||||
<TextField label={t("profileEmail")} value={email} onChange={(e) => setEmail(e.target.value)} autoComplete="email" fullWidth />
|
||||
<TextField label={t("profileCurrentPassword")} value={password} onChange={(e) => setPassword(e.target.value)} autoComplete={allowReg ? "new-password" : "current-password"} type="password" fullWidth />
|
||||
|
||||
<Box sx={{ display: "flex", alignItems: { xs: "flex-start", sm: "center" }, justifyContent: "space-between", gap: 1, flexWrap: "wrap" }}>
|
||||
<FormControlLabel
|
||||
control={<Checkbox checked={rememberMe} onChange={(e) => setRememberMe(e.target.checked)} />}
|
||||
label={t("rememberMe")}
|
||||
/>
|
||||
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap", mt: 0.5 }}>
|
||||
<Button type="button" variant="text" onClick={() => void requestPasswordReset()} disabled={loading || requestingReset}>
|
||||
{requestingReset ? t("loginRequestingReset") : t("forgotPassword")}
|
||||
<Button
|
||||
type="button"
|
||||
variant="text"
|
||||
size="small"
|
||||
onClick={() => void requestPasswordReset()}
|
||||
disabled={resetLoading}
|
||||
sx={{ px: 0, minWidth: 0, fontWeight: 700, alignSelf: { xs: "stretch", sm: "auto" } }}
|
||||
>
|
||||
{resetLoading ? t("passwordResetRequestSending") : t("forgotPassword")}
|
||||
</Button>
|
||||
<Box sx={{ display: "flex", gap: 1, justifyContent: "flex-end" }}>
|
||||
</Box>
|
||||
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mt: -0.5 }}>
|
||||
{rememberMe ? t("rememberMeHelpPersistent") : t("rememberMeHelpSession")}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: "flex", gap: 1, justifyContent: "flex-end", mt: 1 }}>
|
||||
{allowReg && (
|
||||
<Button type="button" variant="outlined" disabled={loading} onClick={() => void submit("register")}>
|
||||
{t("createAccount")}
|
||||
@@ -127,7 +139,6 @@ export default function LoginPage() {
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{tab === 1 && <GoogleAuthCard onSignedIn={() => { navigate(nextPath, { replace: true }); }} />}
|
||||
|
||||
Reference in New Issue
Block a user