Polish settings and auth flows
This commit is contained in:
@@ -43,14 +43,14 @@ jobs:
|
|||||||
|
|
||||||
- name: Test frontend
|
- name: Test frontend
|
||||||
working-directory: job-tracker-ui
|
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
|
- name: Build frontend
|
||||||
working-directory: job-tracker-ui
|
working-directory: job-tracker-ui
|
||||||
env:
|
env:
|
||||||
CI: 'false'
|
CI: 'false'
|
||||||
GENERATE_SOURCEMAP: 'false'
|
GENERATE_SOURCEMAP: 'false'
|
||||||
NODE_OPTIONS: --max-old-space-size=512
|
NODE_OPTIONS: --max-old-space-size=4096
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "react-scripts start",
|
"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",
|
"test": "react-scripts test",
|
||||||
"eject": "react-scripts eject"
|
"eject": "react-scripts eject"
|
||||||
},
|
},
|
||||||
|
|||||||
+77
-45
@@ -1,74 +1,106 @@
|
|||||||
export const AUTH_TOKEN_KEY = "authToken";
|
export const AUTH_TOKEN_KEY = "authToken";
|
||||||
export const AUTH_REMEMBER_ME_KEY = "authRememberMe";
|
export const AUTH_REMEMBER_ME_KEY = "authRememberMe";
|
||||||
const LEGACY_AUTH_TOKEN_KEY = "googleIdToken";
|
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 {
|
try {
|
||||||
return storage.getItem(AUTH_TOKEN_KEY);
|
return storage.getItem(key);
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
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 {
|
export function getRememberMePref(): boolean {
|
||||||
try {
|
return getAuthPersistencePreference() === "local";
|
||||||
return window.localStorage.getItem(AUTH_REMEMBER_ME_KEY) === "1";
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setRememberMePref(value: boolean) {
|
export function setRememberMePref(value: boolean) {
|
||||||
try {
|
persistPreference(value ? "local" : "session");
|
||||||
window.localStorage.setItem(AUTH_REMEMBER_ME_KEY, value ? "1" : "0");
|
|
||||||
} catch {
|
|
||||||
// ignore storage failures
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAuthToken(): string | null {
|
export function getAuthToken(): string | null {
|
||||||
const current = getStoredToken(window.localStorage) || getStoredToken(window.sessionStorage);
|
const localToken = safeGet(window.localStorage, AUTH_TOKEN_KEY);
|
||||||
if (current) return current;
|
if (localToken) {
|
||||||
|
persistPreference("local");
|
||||||
// Backward compat for older builds that stored Google ID tokens under a different key.
|
return localToken;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 }) {
|
export function getAuthPersistencePreference(): AuthPersistence {
|
||||||
const remember = options?.remember ?? getRememberMePref();
|
if (safeGet(window.sessionStorage, AUTH_TOKEN_KEY)) return "session";
|
||||||
try {
|
if (safeGet(window.localStorage, AUTH_TOKEN_KEY)) return "local";
|
||||||
if (remember) {
|
return getStoredPersistence();
|
||||||
window.localStorage.setItem(AUTH_TOKEN_KEY, token);
|
}
|
||||||
window.sessionStorage.removeItem(AUTH_TOKEN_KEY);
|
|
||||||
|
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 {
|
} else {
|
||||||
window.sessionStorage.setItem(AUTH_TOKEN_KEY, token);
|
safeSet(window.localStorage, AUTH_TOKEN_KEY, token);
|
||||||
window.localStorage.removeItem(AUTH_TOKEN_KEY);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
window.localStorage.setItem(AUTH_TOKEN_KEY, token);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
persistPreference(persistence);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearAuthToken() {
|
export function clearAuthToken() {
|
||||||
try {
|
safeRemove(window.localStorage, AUTH_TOKEN_KEY);
|
||||||
window.localStorage.removeItem(AUTH_TOKEN_KEY);
|
safeRemove(window.sessionStorage, AUTH_TOKEN_KEY);
|
||||||
window.sessionStorage.removeItem(AUTH_TOKEN_KEY);
|
|
||||||
} catch {
|
|
||||||
// ignore storage failures
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function decodeJwtPayload(token: string): any {
|
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 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 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() {
|
function getTodayIso() {
|
||||||
return new Date().toISOString().slice(0, 10);
|
return new Date().toISOString().slice(0, 10);
|
||||||
@@ -454,7 +456,7 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
|
|||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2, mt: 1 }}>
|
<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" }}>
|
<Box sx={{ gridColumn: "1 / -1", display: "flex", justifyContent: "flex-end" }}>
|
||||||
<Button onClick={() => void importFromUrl()} disabled={importing || !jobUrl.trim()}>
|
<Button onClick={() => void importFromUrl()} disabled={importing || !jobUrl.trim()}>
|
||||||
{importing ? t("addJobModalImporting") : t("addJobModalImportFromUrl")}
|
{importing ? t("addJobModalImporting") : t("addJobModalImportFromUrl")}
|
||||||
@@ -465,10 +467,10 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
|
|||||||
label={t("addJobModalDateApplied")}
|
label={t("addJobModalDateApplied")}
|
||||||
value={parsePickerDate(dateApplied)}
|
value={parsePickerDate(dateApplied)}
|
||||||
onChange={(value) => setDateApplied(toPickerIso(value))}
|
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) => (
|
{STATUS_OPTIONS.map((s) => (
|
||||||
<MenuItem key={s} value={s}>
|
<MenuItem key={s} value={s}>
|
||||||
{statusLabel(s)}
|
{statusLabel(s)}
|
||||||
@@ -476,15 +478,15 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
|
|||||||
))}
|
))}
|
||||||
</TextField>
|
</TextField>
|
||||||
|
|
||||||
<TextField label={t("addJobModalJobTitle")} value={jobTitle} onChange={(e) => setJobTitle(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)} />
|
<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
|
<DatePicker
|
||||||
label={t("addJobModalDeadline")}
|
label={t("addJobModalDeadline")}
|
||||||
value={parsePickerDate(deadline)}
|
value={parsePickerDate(deadline)}
|
||||||
onChange={(value) => setDeadline(toPickerIso(value))}
|
onChange={(value) => setDeadline(toPickerIso(value))}
|
||||||
slotProps={{ textField: { fullWidth: true } }}
|
slotProps={{ textField: PICKER_TEXT_FIELD_PROPS }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Box sx={{ gridColumn: "1 / -1" }}>
|
<Box sx={{ gridColumn: "1 / -1" }}>
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_OPTIONS = ["Applied", "Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const;
|
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 {
|
function toDateInputValue(isoLike?: string): string {
|
||||||
if (!isoLike) return new Date().toISOString().slice(0, 10);
|
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 }}>
|
<Paper variant="outlined" sx={{ p: 2 }}>
|
||||||
<Typography variant="overline" sx={{ color: "text.secondary" }}>{t("editJobApplicationDetails")}</Typography>
|
<Typography variant="overline" sx={{ color: "text.secondary" }}>{t("editJobApplicationDetails")}</Typography>
|
||||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2, mt: 1 }}>
|
<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")} />} />
|
<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)} />
|
<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: { fullWidth: true } }} />
|
<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)} />
|
<TextField label={t("addJobModalJobUrl")} value={jobUrl} onChange={(e) => setJobUrl(e.target.value)} sx={FIELD_SX} />
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
<Paper variant="outlined" sx={{ p: 2 }}>
|
<Paper variant="outlined" sx={{ p: 2 }}>
|
||||||
<Typography variant="overline" sx={{ color: "text.secondary" }}>{t("editJobStatusUpdate")}</Typography>
|
<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 }}>
|
<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>)}
|
{STATUS_OPTIONS.map((s) => <MenuItem key={s} value={s}>{s}</MenuItem>)}
|
||||||
</TextField>
|
</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>
|
<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 } }} />
|
<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)} />
|
<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: { fullWidth: true } }} />
|
<DatePicker label={t("editJobFollowUpOn")} value={parsePickerDate(followUpAt)} onChange={(value) => setFollowUpAt(toPickerIso(value))} slotProps={{ textField: PICKER_TEXT_FIELD_PROPS }} />
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
<Paper variant="outlined" sx={{ p: 2 }}>
|
<Paper variant="outlined" sx={{ p: 2 }}>
|
||||||
<Typography variant="overline" sx={{ color: "text.secondary" }}>{t("editJobRoleDetails")}</Typography>
|
<Typography variant="overline" sx={{ color: "text.secondary" }}>{t("editJobRoleDetails")}</Typography>
|
||||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2, mt: 1 }}>
|
<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("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("editJobDeadline")} value={parsePickerDate(deadline)} onChange={(value) => setDeadline(toPickerIso(value))} slotProps={{ textField: { fullWidth: true } }} />
|
<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)} />
|
<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>
|
<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("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" }} />
|
<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 { Box, Button, Chip, Paper, Typography } from "@mui/material";
|
||||||
|
|
||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
import { clearAuthToken, decodeJwtPayload, getAuthToken, setAuthToken } from "../auth";
|
import { clearAuthToken, decodeJwtPayload, getAuthPersistencePreference, getAuthToken, setAuthToken } from "../auth";
|
||||||
import { useToast } from "../toast";
|
import { useToast } from "../toast";
|
||||||
import { useI18n } from "../i18n/I18nProvider";
|
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 payload = useMemo(() => (token ? decodeJwtPayload(token) : null), [token]);
|
||||||
const isRawGoogleToken = payload?.iss === "accounts.google.com" || payload?.iss === "https://accounts.google.com";
|
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() {
|
async function refreshMe() {
|
||||||
if (!getAuthToken()) {
|
if (!getAuthToken()) {
|
||||||
setMe(null);
|
setMe(null);
|
||||||
@@ -82,7 +88,7 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void
|
|||||||
try {
|
try {
|
||||||
const res = await api.post<{ accessToken: string }>("/auth/google/exchange", { token });
|
const res = await api.post<{ accessToken: string }>("/auth/google/exchange", { token });
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setAuthToken(res.data.accessToken);
|
setAuthToken(res.data.accessToken, getAuthPersistencePreference());
|
||||||
setToken(res.data.accessToken);
|
setToken(res.data.accessToken);
|
||||||
toast(t("googleSignedIn"), "success");
|
toast(t("googleSignedIn"), "success");
|
||||||
onSignedIn?.();
|
onSignedIn?.();
|
||||||
@@ -125,7 +131,7 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void
|
|||||||
await refreshMe();
|
await refreshMe();
|
||||||
} else {
|
} else {
|
||||||
const res = await api.post<{ accessToken: string }>("/auth/google/exchange", { token: credential });
|
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);
|
setToken(res.data.accessToken);
|
||||||
toast(t("googleSignedIn"), "success");
|
toast(t("googleSignedIn"), "success");
|
||||||
onSignedIn?.();
|
onSignedIn?.();
|
||||||
@@ -191,9 +197,14 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void
|
|||||||
</Typography>
|
</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} />
|
<div ref={hostRef} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", gap: 2, flexWrap: "wrap" }}>
|
||||||
{token ? (
|
{token ? (
|
||||||
<Button
|
<Button
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import {
|
|||||||
Typography,
|
Typography,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
|
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { JobTableColumns } from "./JobTable";
|
import { JobTableColumns } from "./JobTable";
|
||||||
import ImportExportJobs from "./ImportExportJobs";
|
import ImportExportJobs from "./ImportExportJobs";
|
||||||
import GoogleAuthCard from "./GoogleAuthCard";
|
import GoogleAuthCard from "./GoogleAuthCard";
|
||||||
@@ -54,6 +56,7 @@ export default function SettingsView({
|
|||||||
onAccentColorChange,
|
onAccentColorChange,
|
||||||
onResetAccentColor,
|
onResetAccentColor,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const navigate = useNavigate();
|
||||||
const [tab, setTab] = useState(0);
|
const [tab, setTab] = useState(0);
|
||||||
const { language, setLanguage, t } = useI18n();
|
const { language, setLanguage, t } = useI18n();
|
||||||
|
|
||||||
@@ -96,15 +99,14 @@ export default function SettingsView({
|
|||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 2, flexWrap: "wrap" }}>
|
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 2, flexWrap: "wrap" }}>
|
||||||
<input type="hidden" />
|
|
||||||
<FormControl sx={{ width: 160 }}>
|
<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
|
<input
|
||||||
aria-label={t("settingsAccent")}
|
aria-label={t("settingsAccent")}
|
||||||
type="color"
|
type="color"
|
||||||
value={accentOk ? accentColor : "#15803d"}
|
value={accentOk ? accentColor : "#15803d"}
|
||||||
onChange={(e) => onAccentColorChange(e.target.value)}
|
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>
|
</FormControl>
|
||||||
<Button variant="outlined" onClick={onResetAccentColor}>
|
<Button variant="outlined" onClick={onResetAccentColor}>
|
||||||
@@ -119,6 +121,7 @@ export default function SettingsView({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => onAccentColorChange(c)}
|
onClick={() => onAccentColorChange(c)}
|
||||||
title={c}
|
title={c}
|
||||||
|
aria-label={`${t("settingsAccent")} ${c}`}
|
||||||
style={{
|
style={{
|
||||||
width: 28,
|
width: 28,
|
||||||
height: 28,
|
height: 28,
|
||||||
@@ -197,12 +200,7 @@ export default function SettingsView({
|
|||||||
).map(([key, label]) => (
|
).map(([key, label]) => (
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
key={key}
|
key={key}
|
||||||
control={
|
control={<Checkbox checked={columns[key]} onChange={() => onColumnsChange({ ...columns, [key]: !columns[key] })} />}
|
||||||
<Checkbox
|
|
||||||
checked={columns[key]}
|
|
||||||
onChange={() => onColumnsChange({ ...columns, [key]: !columns[key] })}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label={label}
|
label={label}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -215,31 +213,36 @@ export default function SettingsView({
|
|||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
<TabPanel value={tab} index={1}>
|
<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 />
|
<RulesSettingsCard />
|
||||||
|
</Box>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
<TabPanel value={tab} index={2}>
|
<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 }}>
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
|
||||||
<Paper sx={{ p: 2, borderRadius: 3 }}>
|
<Paper sx={{ p: 2 }}>
|
||||||
<Typography sx={{ fontWeight: 800, mb: 0.5 }}>{t("settingsNotificationsFollowUpsTitle")}</Typography>
|
<Typography sx={{ fontWeight: 950, mb: 0.5 }}>{t("settingsNotificationsTitle")}</Typography>
|
||||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("settingsNotificationsFollowUpsBody")}</Typography>
|
<Typography sx={{ color: "text.secondary", mb: 1.5 }}>{t("settingsNotificationsBody")}</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("settingsNotificationsDelivery")}</Typography>
|
||||||
</Paper>
|
</Paper>
|
||||||
<Paper sx={{ p: 2, borderRadius: 3 }}>
|
|
||||||
<Typography sx={{ fontWeight: 800, mb: 0.5 }}>{t("settingsNotificationsAccountTitle")}</Typography>
|
<Paper sx={{ p: 2 }}>
|
||||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("settingsNotificationsAccountBody")}</Typography>
|
<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>
|
</Paper>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: 2 }}>
|
|
||||||
{t("settingsNotificationsDeliveryNote")}
|
|
||||||
</Typography>
|
|
||||||
</Paper>
|
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
<TabPanel value={tab} index={3}>
|
<TabPanel value={tab} index={3}>
|
||||||
|
|||||||
@@ -145,13 +145,16 @@ export const translations = {
|
|||||||
settingsColumnDateApplied: "Date applied",
|
settingsColumnDateApplied: "Date applied",
|
||||||
settingsColumnDays: "Days",
|
settingsColumnDays: "Days",
|
||||||
settingsColumnJobUrl: "Job URL",
|
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",
|
settingsNotificationsTitle: "Email notifications",
|
||||||
settingsNotificationsBody: "Choose how follow-up and account emails are delivered.",
|
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.",
|
||||||
settingsNotificationsFollowUpsTitle: "Follow-up reminders",
|
settingsNotificationsDelivery: "Use the system status page to confirm SMTP is configured before testing outbound email.",
|
||||||
settingsNotificationsFollowUpsBody: "Reminder and ghosting emails use the server SMTP configuration. Delivery follows the timing rules from the Follow-ups tab.",
|
settingsNotificationsWhatYouGetTitle: "What gets sent",
|
||||||
settingsNotificationsAccountTitle: "Account and security emails",
|
settingsNotificationsWhatYouGetBody: "Right now the app sends password reset mail and can surface reminder-driven follow-up workflows. Gmail OAuth stays separate from SMTP delivery.",
|
||||||
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.",
|
settingsCheckSystemStatus: "Check system status",
|
||||||
settingsNotificationsDeliveryNote: "Per-user mailboxes are not selectable yet; the current behavior is one system sender for notifications and reset flows.",
|
|
||||||
profileTitle: "Profile",
|
profileTitle: "Profile",
|
||||||
profileHeadlinePlaceholder: "Add a short headline to personalize your account view.",
|
profileHeadlinePlaceholder: "Add a short headline to personalize your account view.",
|
||||||
profileLocalAccount: "Local account",
|
profileLocalAccount: "Local account",
|
||||||
@@ -522,6 +525,9 @@ export const translations = {
|
|||||||
googleAvailableToLink: "Available to link",
|
googleAvailableToLink: "Available to link",
|
||||||
googleLinkedDate: "Linked {date}",
|
googleLinkedDate: "Linked {date}",
|
||||||
googleSignInHint: "Sign in with a Google account that has already been linked to your Jobbjakt user.",
|
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}.",
|
googleLinkedTo: "Linked to {email}.",
|
||||||
googleLinkedToYourAccount: "Linked to your Google account.",
|
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.",
|
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",
|
google: "Google",
|
||||||
createAccount: "Create account",
|
createAccount: "Create account",
|
||||||
signedIn: "Signed in.",
|
signedIn: "Signed in.",
|
||||||
loginFailed: "Login failed.",
|
rememberMe: "Remember me",
|
||||||
rememberMe: "Remember me on this device",
|
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?",
|
forgotPassword: "Forgot password?",
|
||||||
loginResetEmailRequired: "Enter your email first so we know where to send the reset link.",
|
passwordResetEnterEmail: "Enter your email first, then request a reset link.",
|
||||||
loginRequestingReset: "Sending reset link…",
|
passwordResetRequestSending: "Sending reset link...",
|
||||||
loginResetRequested: "If that account exists, a reset link has been sent.",
|
passwordResetRequestSent: "If that account exists, a reset link has been sent.",
|
||||||
loginResetRequestFailed: "Could not request a password reset.",
|
passwordResetRequestFailed: "Could not send the reset link.",
|
||||||
|
loginFailed: "Login failed.",
|
||||||
resetPasswordTitle: "Reset password",
|
resetPasswordTitle: "Reset password",
|
||||||
resetPasswordBody: "Set a new password for your account.",
|
resetPasswordBody: "Set a new password for your account.",
|
||||||
missingResetLinkInfo: "Missing email/token in link.",
|
missingResetLinkInfo: "Missing email/token in link.",
|
||||||
@@ -969,13 +977,16 @@ export const translations = {
|
|||||||
settingsColumnDateApplied: "Søkt dato",
|
settingsColumnDateApplied: "Søkt dato",
|
||||||
settingsColumnDays: "Dager",
|
settingsColumnDays: "Dager",
|
||||||
settingsColumnJobUrl: "Jobb-URL",
|
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",
|
settingsNotificationsTitle: "E-postvarsler",
|
||||||
settingsNotificationsBody: "Velg hvordan oppfølgings- og kontovarsler leveres.",
|
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.",
|
||||||
settingsNotificationsFollowUpsTitle: "Oppfølgingspåminnelser",
|
settingsNotificationsDelivery: "Bruk siden for systemstatus for å bekrefte at SMTP er konfigurert før du tester utgående e-post.",
|
||||||
settingsNotificationsFollowUpsBody: "Påminnelser og ghosting-e-poster bruker serverens SMTP-oppsett. Leveringen følger tidsreglene på fanen Oppfølging.",
|
settingsNotificationsWhatYouGetTitle: "Hva som sendes",
|
||||||
settingsNotificationsAccountTitle: "Konto- og sikkerhetsmailer",
|
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.",
|
||||||
settingsNotificationsAccountBody: "Tilbakestilling av passord og andre kontovarsler sendes også fra systemets avsender, slik at leveringen er stabil selv uten en personlig postkasse koblet til.",
|
settingsCheckSystemStatus: "Sjekk systemstatus",
|
||||||
settingsNotificationsDeliveryNote: "Per-bruker avsendere kan ikke velges ennå; i dag brukes én systemavsender for varsler og tilbakestilling av passord.",
|
|
||||||
profileTitle: "Profil",
|
profileTitle: "Profil",
|
||||||
profileHeadlinePlaceholder: "Legg til en kort overskrift for å gjøre kontovisningen mer personlig.",
|
profileHeadlinePlaceholder: "Legg til en kort overskrift for å gjøre kontovisningen mer personlig.",
|
||||||
profileLocalAccount: "Lokal konto",
|
profileLocalAccount: "Lokal konto",
|
||||||
@@ -1346,6 +1357,9 @@ export const translations = {
|
|||||||
googleAvailableToLink: "Tilgjengelig for kobling",
|
googleAvailableToLink: "Tilgjengelig for kobling",
|
||||||
googleLinkedDate: "Koblet {date}",
|
googleLinkedDate: "Koblet {date}",
|
||||||
googleSignInHint: "Logg inn med en Google-konto som allerede er koblet til Jobbjakt-brukeren din.",
|
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}.",
|
googleLinkedTo: "Koblet til {email}.",
|
||||||
googleLinkedToYourAccount: "Koblet til Google-kontoen din.",
|
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.",
|
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",
|
google: "Google",
|
||||||
createAccount: "Opprett konto",
|
createAccount: "Opprett konto",
|
||||||
signedIn: "Logget inn.",
|
signedIn: "Logget inn.",
|
||||||
loginFailed: "Innlogging mislyktes.",
|
rememberMe: "Husk meg",
|
||||||
rememberMe: "Husk meg på denne enheten",
|
rememberMeHelpPersistent: "Holder deg innlogget på denne enheten til du logger ut.",
|
||||||
|
rememberMeHelpSession: "Holder deg innlogget bare i denne nettleserøkten.",
|
||||||
forgotPassword: "Glemt passord?",
|
forgotPassword: "Glemt passord?",
|
||||||
loginResetEmailRequired: "Skriv inn e-postadressen først, så vet vi hvor vi skal sende lenken.",
|
passwordResetEnterEmail: "Skriv inn e-post først, og be deretter om en nullstillingslenke.",
|
||||||
loginRequestingReset: "Sender tilbakestillingslenke…",
|
passwordResetRequestSending: "Sender nullstillingslenke...",
|
||||||
loginResetRequested: "Hvis kontoen finnes, er en tilbakestillingslenke sendt.",
|
passwordResetRequestSent: "Hvis kontoen finnes, er en nullstillingslenke sendt.",
|
||||||
loginResetRequestFailed: "Kunne ikke be om tilbakestilling av passord.",
|
passwordResetRequestFailed: "Kunne ikke sende nullstillingslenken.",
|
||||||
|
loginFailed: "Innlogging mislyktes.",
|
||||||
resetPasswordTitle: "Tilbakestill passord",
|
resetPasswordTitle: "Tilbakestill passord",
|
||||||
resetPasswordBody: "Sett et nytt passord for kontoen din.",
|
resetPasswordBody: "Sett et nytt passord for kontoen din.",
|
||||||
missingResetLinkInfo: "Mangler e-post/token i lenken.",
|
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 { Box, Button, Checkbox, FormControlLabel, Paper, Tab, Tabs, TextField, Typography } from "@mui/material";
|
||||||
|
|
||||||
import { useLocation, useNavigate } from "react-router-dom";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { api } from "../api";
|
import { api, getApiErrorMessage } from "../api";
|
||||||
import { setAuthToken, setRememberMePref, getRememberMePref } from "../auth";
|
import { getRememberMePref, setAuthToken, setRememberMePref } from "../auth";
|
||||||
import GoogleAuthCard from "../components/GoogleAuthCard";
|
import GoogleAuthCard from "../components/GoogleAuthCard";
|
||||||
import { useToast } from "../toast";
|
import { useToast } from "../toast";
|
||||||
import { useI18n } from "../i18n/I18nProvider";
|
import { useI18n } from "../i18n/I18nProvider";
|
||||||
@@ -28,11 +28,12 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [rememberMe, setRememberMe] = useState(() => getRememberMePref());
|
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 nextPath = (location?.state?.from as string | undefined) ?? "/jobs";
|
||||||
|
const canRequestPasswordReset = useMemo(() => email.trim().length > 0, [email]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api
|
api
|
||||||
@@ -47,32 +48,30 @@ export default function LoginPage() {
|
|||||||
const url = mode === "register" ? "/auth/register" : "/auth/login";
|
const url = mode === "register" ? "/auth/register" : "/auth/login";
|
||||||
const res = await api.post<{ accessToken: string; tokenType: string }>(url, { email, password });
|
const res = await api.post<{ accessToken: string; tokenType: string }>(url, { email, password });
|
||||||
setRememberMePref(rememberMe);
|
setRememberMePref(rememberMe);
|
||||||
setAuthToken(res.data.accessToken, { remember: rememberMe });
|
setAuthToken(res.data.accessToken, rememberMe ? "local" : "session");
|
||||||
toast(t("signedIn"), "success");
|
toast(t("signedIn"), "success");
|
||||||
navigate(nextPath, { replace: true });
|
navigate(nextPath, { replace: true });
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
const msg = e?.response?.data || e?.message || t("loginFailed");
|
toast(getApiErrorMessage(e, t("loginFailed")), "error");
|
||||||
toast(String(msg), "error");
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function requestPasswordReset() {
|
async function requestPasswordReset() {
|
||||||
if (!email.trim()) {
|
if (!canRequestPasswordReset) {
|
||||||
toast(t("loginResetEmailRequired"), "error");
|
toast(t("passwordResetEnterEmail"), "info");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setRequestingReset(true);
|
setResetLoading(true);
|
||||||
try {
|
try {
|
||||||
await api.post("/auth/request-password-reset", { email: email.trim() });
|
await api.post("/auth/request-password-reset", { email: email.trim() });
|
||||||
toast(t("loginResetRequested"), "success");
|
toast(t("passwordResetRequestSent"), "success");
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
const msg = e?.response?.data || e?.message || t("loginResetRequestFailed");
|
toast(getApiErrorMessage(e, t("passwordResetRequestFailed")), "error");
|
||||||
toast(String(msg), "error");
|
|
||||||
} finally {
|
} 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 }}>
|
<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("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 />
|
<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
|
<FormControlLabel
|
||||||
control={<Checkbox checked={rememberMe} onChange={(e) => setRememberMe(e.target.checked)} />}
|
control={<Checkbox checked={rememberMe} onChange={(e) => setRememberMe(e.target.checked)} />}
|
||||||
label={t("rememberMe")}
|
label={t("rememberMe")}
|
||||||
/>
|
/>
|
||||||
|
<Button
|
||||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap", mt: 0.5 }}>
|
type="button"
|
||||||
<Button type="button" variant="text" onClick={() => void requestPasswordReset()} disabled={loading || requestingReset}>
|
variant="text"
|
||||||
{requestingReset ? t("loginRequestingReset") : t("forgotPassword")}
|
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>
|
</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 && (
|
{allowReg && (
|
||||||
<Button type="button" variant="outlined" disabled={loading} onClick={() => void submit("register")}>
|
<Button type="button" variant="outlined" disabled={loading} onClick={() => void submit("register")}>
|
||||||
{t("createAccount")}
|
{t("createAccount")}
|
||||||
@@ -127,7 +139,6 @@ export default function LoginPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === 1 && <GoogleAuthCard onSignedIn={() => { navigate(nextPath, { replace: true }); }} />}
|
{tab === 1 && <GoogleAuthCard onSignedIn={() => { navigate(nextPath, { replace: true }); }} />}
|
||||||
|
|||||||
Reference in New Issue
Block a user