Polish settings and auth flows
This commit is contained in:
@@ -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}>
|
||||
<RulesSettingsCard />
|
||||
<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 }}>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
</Box>
|
||||
|
||||
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: 2 }}>
|
||||
{t("settingsNotificationsDeliveryNote")}
|
||||
</Typography>
|
||||
</Paper>
|
||||
<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>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={tab} index={3}>
|
||||
|
||||
Reference in New Issue
Block a user