Localize kanban, auth, backup, and admin utilities

This commit is contained in:
cesnimda
2026-03-23 21:05:49 +01:00
parent 9661a321da
commit 2c03379504
11 changed files with 181 additions and 75 deletions
@@ -313,7 +313,7 @@ export default function Attachments({ jobId }: { jobId: number }) {
)}
</DialogContent>
<DialogActions>
{preview ? <Button onClick={() => void download({ id: 0, fileName: preview.name, fileType: preview.type, fileSize: 0, uploadDate: "" } as AttachmentItem)}>Download</Button> : null}
{preview ? <Button onClick={() => void download({ id: 0, fileName: preview.name, fileType: preview.type, fileSize: 0, uploadDate: "" } as AttachmentItem)}>{t("attachmentsDownload")}</Button> : null}
<Button onClick={() => setPreviewOpen(false)}>{t("close")}</Button>
</DialogActions>
</Dialog>
@@ -5,6 +5,7 @@ import { Box, Button, Paper, Typography } from "@mui/material";
import { api } from "../api";
import { clearAuthToken, getAuthToken } from "../auth";
import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider";
type MeResponse = {
provider?: string;
@@ -23,6 +24,7 @@ type MeResponse = {
export default function AuthStatusCard() {
const { toast } = useToast();
const { t } = useI18n();
const token = getAuthToken();
const [me, setMe] = useState<MeResponse | null>(null);
@@ -42,11 +44,11 @@ export default function AuthStatusCard() {
return (
<Paper sx={{ mt: 2, p: 2 }}>
<Typography variant="h6" sx={{ mb: 1 }}>
Authentication
{t("authStatusTitle")}
</Typography>
{!token ? (
<Typography sx={{ color: "text.secondary" }}>Not signed in.</Typography>
<Typography sx={{ color: "text.secondary" }}>{t("authStatusNotSignedIn")}</Typography>
) : (
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.5 }}>
<Typography sx={{ color: "text.secondary" }}>
@@ -54,12 +56,12 @@ export default function AuthStatusCard() {
</Typography>
{me?.roles && me.roles.length > 0 ? (
<Typography sx={{ color: "text.secondary" }}>
Roles: {me.roles.join(", ")}
{t("authStatusRoles", { roles: me.roles.join(", ") })}
</Typography>
) : null}
{me?.googleLink?.linked ? (
<Typography sx={{ color: "text.secondary" }}>
Google linked{me.googleLink.email ? `: ${me.googleLink.email}` : "."}
{t("authStatusGoogleLinked", { suffix: me.googleLink.email ? `: ${me.googleLink.email}` : "." })}
</Typography>
) : null}
@@ -69,10 +71,10 @@ export default function AuthStatusCard() {
onClick={() => {
clearAuthToken();
setMe(null);
toast("Signed out.", "info");
toast(t("signedOut"), "info");
}}
>
Sign out
{t("signOut")}
</Button>
</Box>
</Box>
+7 -5
View File
@@ -3,9 +3,11 @@ import React, { useState } from "react";
import { Box, Button, Paper, Typography } from "@mui/material";
import { api } from "../api";
import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider";
export default function BackupCard() {
const { toast } = useToast();
const { t } = useI18n();
const [downloading, setDownloading] = useState(false);
const downloadEncrypted = async () => {
@@ -25,9 +27,9 @@ export default function BackupCard() {
link.click();
link.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 5000);
toast("Backup downloaded.", "success");
toast(t("backupDownloaded"), "success");
} catch {
toast("Backup failed.", "error");
toast(t("backupFailed"), "error");
} finally {
setDownloading(false);
}
@@ -36,14 +38,14 @@ export default function BackupCard() {
return (
<Paper sx={{ mt: 2, p: 2 }}>
<Typography variant="h6" sx={{ mb: 1 }}>
Data Safety
{t("backupTitle")}
</Typography>
<Typography sx={{ color: "text.secondary", mb: 1 }}>
One-click encrypted backup (Windows DPAPI).
{t("backupBody")}
</Typography>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
<Button variant="contained" onClick={downloadEncrypted} disabled={downloading}>
{downloading ? "Preparing..." : "Download Encrypted Backup"}
{downloading ? t("backupPreparing") : t("backupDownload")}
</Button>
</Box>
</Paper>
@@ -233,7 +233,7 @@ export default function Correspondence({ jobId }: { jobId: number }) {
await load();
toast(t("correspondenceLogEmail"), "success");
} catch (error) {
toast(getApiErrorMessage(error, "Failed to import email."), "error");
toast(getApiErrorMessage(error, t("addJobModalImportFailed")), "error");
}
};
@@ -241,9 +241,9 @@ export default function Correspondence({ jobId }: { jobId: number }) {
try {
const res = await api.get<{ url: string }>("/gmail/connect-url");
const popup = window.open(res.data.url, "jobtracker-gmail-connect", "width=620,height=760,resizable=yes,scrollbars=yes");
if (!popup) toast("Your browser blocked the Gmail popup.", "error");
if (!popup) toast(t("correspondenceBlockedPopup"), "error");
} catch (error) {
toast(getApiErrorMessage(error, "Failed to start Gmail connection."), "error");
toast(getApiErrorMessage(error, t("correspondenceStartGmailFailed")), "error");
}
};
@@ -254,18 +254,18 @@ export default function Correspondence({ jobId }: { jobId: number }) {
setGmailMessages([]);
toast(t("googleUnlinked"), "success");
} catch (error) {
toast(getApiErrorMessage(error, "Failed to disconnect Gmail."), "error");
toast(getApiErrorMessage(error, t("correspondenceDisconnectFailed")), "error");
}
};
const deleteMessage = async (messageId: number) => {
if (!(await confirmAction("Remove this correspondence message?", { title: "Delete message", confirmLabel: t("jobTableDeleteSelected"), destructive: true }))) return;
if (!(await confirmAction(t("correspondenceDeleteConfirm"), { title: t("correspondenceDeleteTitle"), confirmLabel: t("adminUsersDelete"), destructive: true }))) return;
try {
await api.delete(`/correspondence/${messageId}`);
await load();
toast("Message removed.", "success");
toast(t("correspondenceDeleted"), "success");
} catch (error) {
toast(getApiErrorMessage(error, "Failed to remove message."), "error");
toast(getApiErrorMessage(error, t("correspondenceDeleteFailed")), "error");
}
};
@@ -276,7 +276,7 @@ export default function Correspondence({ jobId }: { jobId: number }) {
await load();
toast(t("correspondenceImportEmail"), "success");
} catch (error: any) {
toast(getApiErrorMessage(error, "Failed to import Gmail message."), "error");
toast(getApiErrorMessage(error, t("correspondenceImportGmailFailed")), "error");
} finally {
setImportingMessageId(null);
}
@@ -287,9 +287,9 @@ export default function Correspondence({ jobId }: { jobId: number }) {
setImportingThreadId(threadId);
const res = await api.post<{ imported: number; skipped: number; threadId?: string }>("/gmail/import-thread", { jobApplicationId: jobId, threadId, messageIds });
await load();
toast(`Imported ${res.data.imported} messages${res.data.skipped ? `, skipped ${res.data.skipped} duplicates` : ""}.`, "success");
toast(t("correspondenceImportThreadResult", { imported: res.data.imported, skippedText: res.data.skipped ? t("correspondenceImportThreadSkipped", { count: res.data.skipped }) : "" }), "success");
} catch (error: any) {
toast(getApiErrorMessage(error, "Failed to import Gmail thread."), "error");
toast(getApiErrorMessage(error, t("correspondenceImportThreadFailed")), "error");
} finally {
setImportingThreadId(null);
}
@@ -299,7 +299,7 @@ export default function Correspondence({ jobId }: { jobId: number }) {
<Box>
<Paper ref={scrollRef} sx={{ p: 1.5, maxHeight: 360, overflowY: "auto", background: theme.palette.mode === "dark" ? "rgba(15,23,42,0.45)" : "rgba(255,255,255,0.75)", backdropFilter: "blur(8px)" }}>
{messages.length === 0 ? (
<Typography sx={{ color: "text.secondary", py: 2, textAlign: "center" }}>No messages yet.</Typography>
<Typography sx={{ color: "text.secondary", py: 2, textAlign: "center" }}>{t("correspondenceNoMessages")}</Typography>
) : (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
{messages.map((m) => {
@@ -384,12 +384,12 @@ export default function Correspondence({ jobId }: { jobId: number }) {
<TextField label={t("correspondenceSearchGmail")} value={gmailQuery} onChange={(e) => setGmailQuery(e.target.value)} placeholder={t("correspondenceSearchGmailPlaceholder")} size="small" fullWidth />
<Button variant="outlined" onClick={() => void loadGmailMessages()} disabled={gmailMessagesLoading}>{t("correspondenceSearch")}</Button>
</Box>
{gmailStatus.lastSyncedAt ? <Chip label={`Last synced ${new Date(gmailStatus.lastSyncedAt).toLocaleString()}`} size="small" /> : null}
{gmailStatus.lastSyncedAt ? <Chip label={t("correspondenceLastSynced", { date: new Date(gmailStatus.lastSyncedAt).toLocaleString() })} size="small" /> : null}
<Paper variant="outlined" sx={{ maxHeight: 420, overflowY: "auto" }}>
{gmailMessagesLoading ? (
<Box sx={{ py: 5, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box>
) : groupedByThread.length === 0 ? (
<Typography sx={{ color: "text.secondary", p: 2 }}>No Gmail messages found.</Typography>
<Typography sx={{ color: "text.secondary", p: 2 }}>{t("correspondenceNoGmailMessages")}</Typography>
) : (
<List disablePadding>
{groupedByThread.map(({ threadId, items }, threadIndex) => (
@@ -398,11 +398,11 @@ export default function Correspondence({ jobId }: { jobId: number }) {
<Box sx={{ p: 1.5, backgroundColor: alpha(theme.palette.primary.main, 0.04) }}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center", mb: 1 }}>
<Box>
<Typography sx={{ fontWeight: 800 }}>{items[0]?.subject || "(No subject)"}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{items.length} message{items.length === 1 ? "" : "s"} in thread</Typography>
<Typography sx={{ fontWeight: 800 }}>{items[0]?.subject || t("correspondenceNoSubject")}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("correspondenceMessagesInThread", { count: items.length })}</Typography>
</Box>
<Button startIcon={<MailOutlineIcon />} variant="outlined" size="small" disabled={importingThreadId === threadId} onClick={() => void importGmailThread(threadId, items.map((x) => x.id))}>
{importingThreadId === threadId ? "Importing..." : "Import thread"}
{importingThreadId === threadId ? t("correspondenceImporting") : t("correspondenceImportThread")}
</Button>
</Box>
{items.map((message, index) => (
@@ -410,11 +410,11 @@ export default function Correspondence({ jobId }: { jobId: number }) {
{index > 0 ? <Divider sx={{ my: 1 }} /> : null}
<ListItemButton sx={{ alignItems: "flex-start", px: 0, py: 1 }}>
<ListItemText
primary={<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap" }}><Typography sx={{ fontWeight: 700 }}>{message.subject || "(No subject)"}</Typography><Typography variant="caption" sx={{ color: "text.secondary" }}>{message.date ? new Date(message.date).toLocaleString() : ""}</Typography></Box>}
secondary={<Box sx={{ mt: 0.5 }}><Typography variant="body2" sx={{ color: "text.primary" }}>From: {message.from || t("correspondenceUnknown")}</Typography><Typography variant="body2" sx={{ color: "text.secondary", mt: 0.25 }}>{message.snippet}</Typography></Box>}
primary={<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap" }}><Typography sx={{ fontWeight: 700 }}>{message.subject || t("correspondenceNoSubject")}</Typography><Typography variant="caption" sx={{ color: "text.secondary" }}>{message.date ? new Date(message.date).toLocaleString() : ""}</Typography></Box>}
secondary={<Box sx={{ mt: 0.5 }}><Typography variant="body2" sx={{ color: "text.primary" }}>{t("correspondenceFromLabel", { value: message.from || t("correspondenceUnknown") })}</Typography><Typography variant="body2" sx={{ color: "text.secondary", mt: 0.25 }}>{message.snippet}</Typography></Box>}
/>
<Button variant="contained" size="small" disabled={importingMessageId === message.id} onClick={() => void importGmailMessage(message.id)}>
{importingMessageId === message.id ? "Importing..." : "Import"}
{importingMessageId === message.id ? t("correspondenceImporting") : t("correspondenceImportEmail")}
</Button>
</ListItemButton>
</React.Fragment>
@@ -1,16 +1,22 @@
import React from "react";
import { api } from "../api";
import { useI18n } from "../i18n/I18nProvider";
type Props = {
children: React.ReactNode;
};
type ErrorBoundaryInnerProps = Props & {
t: (key: "errorBoundaryTitle" | "errorBoundaryBody" | "errorBoundaryUnknown" | "errorBoundaryRefresh") => string;
};
type State = {
hasError: boolean;
errorId?: string;
};
export default class ErrorBoundary extends React.Component<Props, State> {
class ErrorBoundaryInner extends React.Component<ErrorBoundaryInnerProps, State> {
state: State = { hasError: false };
static getDerivedStateFromError(_: any) {
@@ -21,7 +27,6 @@ export default class ErrorBoundary extends React.Component<Props, State> {
const errorId = `ui_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
this.setState({ errorId });
// Best-effort: report to API. If offline/misconfigured, we still show fallback UI.
void api.post("/client-errors", {
errorId,
message: String(error?.message ?? error),
@@ -38,16 +43,20 @@ export default class ErrorBoundary extends React.Component<Props, State> {
return (
<div style={{ padding: 24, maxWidth: 820, margin: "0 auto" }}>
<h2 style={{ margin: "0 0 8px 0" }}>Something crashed.</h2>
<h2 style={{ margin: "0 0 8px 0" }}>{this.props.t("errorBoundaryTitle")}</h2>
<div style={{ opacity: 0.8, marginBottom: 16 }}>
Try refreshing. If it keeps happening, capture this ID:
{this.props.t("errorBoundaryBody")}
<div style={{ fontFamily: "monospace", marginTop: 6 }}>
{this.state.errorId ?? "unknown"}
{this.state.errorId ?? this.props.t("errorBoundaryUnknown")}
</div>
</div>
<button onClick={() => window.location.reload()}>Refresh</button>
<button onClick={() => window.location.reload()}>{this.props.t("errorBoundaryRefresh")}</button>
</div>
);
}
}
export default function ErrorBoundary(props: Props) {
const { t } = useI18n();
return <ErrorBoundaryInner {...props} t={t} />;
}
+23 -4
View File
@@ -35,6 +35,25 @@ function toneColor(theme: any, status: Status | "Other"): string {
return theme.palette.primary.main;
}
function statusLabel(t: (key: any, params?: any) => string, status: Status): string {
switch (status) {
case "Applied":
return t("statusApplied");
case "Waiting":
return t("statusWaiting");
case "Interview":
return t("statusInterview");
case "Offer":
return t("statusOffer");
case "Rejected":
return t("statusRejected");
case "Ghosted":
return t("statusGhosted");
default:
return status;
}
}
export default function KanbanBoard() {
const theme = useTheme();
const { t } = useI18n();
@@ -79,7 +98,7 @@ export default function KanbanBoard() {
return (
<Box sx={{ mt: 2 }}>
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1 }}>
Drag cards between columns to update status.
{t("kanbanHint")}
</Typography>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(3, 1fr)", xl: "repeat(6, 1fr)" }, gap: 2, alignItems: "start" }}>
@@ -101,7 +120,7 @@ export default function KanbanBoard() {
>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 800, color: theme.palette.mode === "dark" ? "#f8fafc" : "inherit" }}>
{status}
{statusLabel(t, status)}
</Typography>
<Chip
size="small"
@@ -160,7 +179,7 @@ export default function KanbanBoard() {
))}
{list.length === 0 && (
<Typography variant="body2" sx={{ color: "text.secondary", py: 1 }}>
Drop here
{t("kanbanDropHere")}
</Typography>
)}
</Box>
@@ -174,7 +193,7 @@ export default function KanbanBoard() {
.filter((s) => s !== currentMenuStatus)
.map((s) => (
<MenuItem key={s} onClick={() => { if (menuJobId) void setStatus(menuJobId, s); setMenuAnchor(null); setMenuJobId(null); }}>
{t("jobTableSetStatus", { status: s })}
{t("jobTableSetStatus", { status: statusLabel(t, s) })}
</MenuItem>
))}
</Menu>
@@ -33,7 +33,7 @@ export default function RulesSettingsCard() {
await api.put("/rules", s);
toast(t("rulesSave"), "success");
} catch {
toast("Failed to save rules.", "error");
toast(t("rulesSaveFailed"), "error");
} finally {
setSaving(false);
}
@@ -6,6 +6,7 @@ import { api } from "../api";
import { getAuthToken } from "../auth";
import { useToast } from "../toast";
import { useDialogActions } from "../dialogs";
import { useI18n } from "../i18n/I18nProvider";
type UserDto = {
id: string;
@@ -18,6 +19,7 @@ type UserDto = {
export default function UserManagementCard() {
const { toast } = useToast();
const { confirmAction } = useDialogActions();
const { t } = useI18n();
const token = getAuthToken();
const [supported, setSupported] = useState<boolean | null>(null);
@@ -41,7 +43,7 @@ export default function UserManagementCard() {
if (status === 401 || status === 403) {
setSupported(false);
} else {
toast("Failed to load users.", "error");
toast(t("adminUsersLoadFailed"), "error");
setSupported(false);
}
} finally {
@@ -65,21 +67,21 @@ export default function UserManagementCard() {
return (
<Paper sx={{ mt: 2, p: 2 }}>
<Typography variant="h6" sx={{ mb: 1 }}>
User management
{t("adminUsersTitle")}
</Typography>
<Typography sx={{ color: "text.secondary", mb: 2 }}>
Admin-only.
{t("adminUsersSubtitle")}
</Typography>
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1.5 }}>
<TextField
label="Email"
label={t("profileEmail")}
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
fullWidth
/>
<TextField
label="Password"
label={t("adminUsersPassword")}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
type="password"
@@ -92,7 +94,7 @@ export default function UserManagementCard() {
variant={newIsAdmin ? "contained" : "outlined"}
onClick={() => setNewIsAdmin((v) => !v)}
>
{newIsAdmin ? "Admin: Yes" : "Admin: No"}
{newIsAdmin ? t("adminUsersAdminYes") : t("adminUsersAdminNo")}
</Button>
<Button
variant="contained"
@@ -107,21 +109,21 @@ export default function UserManagementCard() {
setNewEmail("");
setNewPassword("");
setNewIsAdmin(false);
toast("User created.", "success");
toast(t("adminUsersCreated"), "success");
await load();
} catch (e: any) {
const msg = e?.response?.data || e?.message || "Failed to create user.";
const msg = e?.response?.data || e?.message || t("adminUsersCreateFailed");
toast(String(msg), "error");
}
}}
>
Create user
{t("adminUsersCreateUser")}
</Button>
</Box>
<Box sx={{ mt: 2, display: "flex", flexDirection: "column", gap: 1 }}>
{users.length === 0 ? (
<Typography sx={{ color: "text.secondary" }}>No users.</Typography>
<Typography sx={{ color: "text.secondary" }}>{t("adminUsersNoUsers")}</Typography>
) : (
users.map((u) => (
<Box
@@ -140,10 +142,10 @@ export default function UserManagementCard() {
>
<Box>
<Typography sx={{ fontWeight: 900, lineHeight: 1.2 }}>
{u.email || u.userName || u.id}
{u.userName || u.email || u.id}
</Typography>
<Typography variant="caption" sx={{ color: "text.secondary" }}>
Roles: {u.roles?.length ? u.roles.join(", ") : "—"}
{t("adminUsersRolesLabel")}: {u.roles?.length ? u.roles.join(", ") : "—"}
</Typography>
</Box>
@@ -151,17 +153,17 @@ export default function UserManagementCard() {
variant="outlined"
color="error"
onClick={async () => {
if (!(await confirmAction("Delete this user?", { title: "Delete user", confirmLabel: "Delete", destructive: true }))) return;
if (!(await confirmAction(t("adminUsersDeleteConfirmBody"), { title: t("adminUsersDeleteConfirmTitle"), confirmLabel: t("adminUsersDelete"), destructive: true }))) return;
try {
await api.delete(`/users/${u.id}`);
toast("User deleted.", "info");
toast(t("adminUsersDeleted"), "info");
await load();
} catch {
toast("Failed to delete user.", "error");
toast(t("adminUsersDeleteFailed"), "error");
}
}}
>
Delete
{t("adminUsersDelete")}
</Button>
<Button
@@ -169,14 +171,14 @@ export default function UserManagementCard() {
onClick={async () => {
try {
await api.post(`/users/${u.id}/send-password-reset`);
toast("Password reset email sent.", "success");
toast(t("adminUsersResetSent"), "success");
} catch (e: any) {
const msg = e?.response?.data || e?.message || "Failed to send reset email.";
const msg = e?.response?.data || e?.message || t("adminUsersResetFailed");
toast(String(msg), "error");
}
}}
>
Send reset
{t("adminUsersSendReset")}
</Button>
</Box>
))
+72
View File
@@ -54,6 +54,8 @@ export const translations = {
save: "Save",
create: "Create",
createJob: "Create job",
yes: "Yes",
noWord: "No",
createAndAddAnother: "Create & add another",
loading: "Loading...",
notFoundTitle: "Page not found",
@@ -259,6 +261,7 @@ export const translations = {
adminUsersAdmin: "Admin",
adminUsersSendReset: "Send reset",
adminUsersDelete: "Delete",
adminUsersRolesLabel: "Roles",
adminUsersConfirmed: "Confirmed",
adminUsersActions: "Actions",
adminUsersNoUsers: "No users.",
@@ -271,6 +274,13 @@ export const translations = {
adminUsersDeleteFailed: "Failed to delete user.",
adminUsersCreated: "User created.",
adminUsersCreateFailed: "Failed to create user.",
adminUsersLoadFailed: "Failed to load users.",
adminUsersAdminYes: "Admin: Yes",
adminUsersAdminNo: "Admin: No",
adminUsersDeleteConfirmBody: "Delete this user?",
adminUsersPassword: "Password",
kanbanHint: "Drag cards between columns to update status.",
kanbanDropHere: "Drop here",
adminSystemTitle: "System status",
adminSystemSubtitle: "Production diagnostics for runtime, database, auth, email, AI service health, and OCR readiness.",
adminSystemRunProbe: "Run probe now",
@@ -352,6 +362,17 @@ export const translations = {
correspondenceImportThread: "Import thread",
correspondenceImporting: "Importing...",
correspondenceFromLabel: "From: {value}",
correspondenceBlockedPopup: "Your browser blocked the Gmail popup.",
correspondenceStartGmailFailed: "Failed to start Gmail connection.",
correspondenceDisconnectFailed: "Failed to disconnect Gmail.",
correspondenceDeleteConfirm: "Remove this correspondence message?",
correspondenceDeleteTitle: "Delete message",
correspondenceDeleted: "Message removed.",
correspondenceDeleteFailed: "Failed to remove message.",
correspondenceImportGmailFailed: "Failed to import Gmail message.",
correspondenceImportThreadResult: "Imported {imported} messages{skippedText}.",
correspondenceImportThreadSkipped: ", skipped {count} duplicates",
correspondenceImportThreadFailed: "Failed to import Gmail thread.",
attachmentsTitle: "Attachments ({count})",
attachmentsSubtitle: "Upload resumes, cover letters, portfolios, and supporting files for this application.",
attachmentsImages: "{count} images",
@@ -446,6 +467,20 @@ export const translations = {
signedOut: "Signed out.",
signedInAs: "Signed in as {name}.",
unlinkGoogle: "Unlink Google",
backupTitle: "Data safety",
backupBody: "One-click encrypted backup (Windows DPAPI).",
backupPreparing: "Preparing...",
backupDownload: "Download encrypted backup",
backupDownloaded: "Backup downloaded.",
backupFailed: "Backup failed.",
authStatusTitle: "Authentication",
authStatusNotSignedIn: "Not signed in.",
authStatusRoles: "Roles: {roles}",
authStatusGoogleLinked: "Google linked{suffix}",
errorBoundaryTitle: "Something crashed.",
errorBoundaryBody: "Try refreshing. If it keeps happening, capture this ID:",
errorBoundaryUnknown: "unknown",
errorBoundaryRefresh: "Refresh",
importExportTitle: "Import / Export",
importExportBody: "Import expects the JSON exported by this app (an array of job objects with embedded company).",
exportJson: "Export JSON",
@@ -676,6 +711,7 @@ export const translations = {
rulesFeedbackGhostDays: "Feedback: ghost days",
rulesSaving: "Saving...",
rulesSave: "Save Rules",
rulesSaveFailed: "Failed to save rules.",
},
no: {
appTitle: "Jobbjakt",
@@ -730,6 +766,8 @@ export const translations = {
save: "Lagre",
create: "Opprett",
createJob: "Opprett jobb",
yes: "Ja",
noWord: "Nei",
createAndAddAnother: "Opprett og legg til en til",
loading: "Laster...",
notFoundTitle: "Siden ble ikke funnet",
@@ -935,6 +973,7 @@ export const translations = {
adminUsersAdmin: "Admin",
adminUsersSendReset: "Send tilbakestilling",
adminUsersDelete: "Slett",
adminUsersRolesLabel: "Roller",
adminUsersConfirmed: "Bekreftet",
adminUsersActions: "Handlinger",
adminUsersNoUsers: "Ingen brukere.",
@@ -947,6 +986,13 @@ export const translations = {
adminUsersDeleteFailed: "Kunne ikke slette bruker.",
adminUsersCreated: "Bruker opprettet.",
adminUsersCreateFailed: "Kunne ikke opprette bruker.",
adminUsersLoadFailed: "Kunne ikke laste brukere.",
adminUsersAdminYes: "Admin: Ja",
adminUsersAdminNo: "Admin: Nei",
adminUsersDeleteConfirmBody: "Slette denne brukeren?",
adminUsersPassword: "Passord",
kanbanHint: "Dra kort mellom kolonnene for å oppdatere status.",
kanbanDropHere: "Slipp her",
adminSystemTitle: "Systemstatus",
adminSystemSubtitle: "Produksjonsdiagnostikk for kjøretid, database, autentisering, e-post, AI-tjenestehelse og OCR-beredskap.",
adminSystemRunProbe: "Kjør probe nå",
@@ -1028,6 +1074,17 @@ export const translations = {
correspondenceImportThread: "Importer tråd",
correspondenceImporting: "Importerer...",
correspondenceFromLabel: "Fra: {value}",
correspondenceBlockedPopup: "Nettleseren din blokkerte Gmail-popupen.",
correspondenceStartGmailFailed: "Kunne ikke starte Gmail-tilkobling.",
correspondenceDisconnectFailed: "Kunne ikke koble fra Gmail.",
correspondenceDeleteConfirm: "Fjerne denne meldingen fra korrespondansen?",
correspondenceDeleteTitle: "Slett melding",
correspondenceDeleted: "Melding fjernet.",
correspondenceDeleteFailed: "Kunne ikke fjerne melding.",
correspondenceImportGmailFailed: "Kunne ikke importere Gmail-melding.",
correspondenceImportThreadResult: "Importerte {imported} meldinger{skippedText}.",
correspondenceImportThreadSkipped: ", hoppet over {count} duplikater",
correspondenceImportThreadFailed: "Kunne ikke importere Gmail-tråd.",
attachmentsTitle: "Vedlegg ({count})",
attachmentsSubtitle: "Last opp CV-er, søknadsbrev, porteføljer og støttedokumenter for denne søknaden.",
attachmentsImages: "{count} bilder",
@@ -1122,6 +1179,20 @@ export const translations = {
signedOut: "Logget ut.",
signedInAs: "Logget inn som {name}.",
unlinkGoogle: "Koble fra Google",
backupTitle: "Datasikkerhet",
backupBody: "Kryptert sikkerhetskopi med ett klikk (Windows DPAPI).",
backupPreparing: "Forbereder...",
backupDownload: "Last ned kryptert sikkerhetskopi",
backupDownloaded: "Sikkerhetskopi lastet ned.",
backupFailed: "Sikkerhetskopiering mislyktes.",
authStatusTitle: "Autentisering",
authStatusNotSignedIn: "Ikke logget inn.",
authStatusRoles: "Roller: {roles}",
authStatusGoogleLinked: "Google koblet{suffix}",
errorBoundaryTitle: "Noe krasjet.",
errorBoundaryBody: "Prøv å laste inn siden på nytt. Hvis det fortsetter, ta vare på denne ID-en:",
errorBoundaryUnknown: "ukjent",
errorBoundaryRefresh: "Oppdater",
importExportTitle: "Import / eksport",
importExportBody: "Import forventer JSON eksportert av denne appen (en matrise med jobbobjekter med innebygd selskap).",
exportJson: "Eksporter JSON",
@@ -1352,6 +1423,7 @@ export const translations = {
rulesFeedbackGhostDays: "Tilbakemelding: ghostingdager",
rulesSaving: "Lagrer...",
rulesSave: "Lagre regler",
rulesSaveFailed: "Kunne ikke lagre regler.",
},
} as const;
+12 -12
View File
@@ -249,14 +249,14 @@ export default function AdminSystemPage() {
<Stack spacing={0.75}>
<DetailRow label={t("adminSystemProvider")} value={status?.database.provider || "-"} />
<DetailRow label={t("adminSystemTarget")} value={status?.database.target || "-"} />
<DetailRow label={t("adminSystemConfigured")} value={status?.database.looksConfigured ? "Yes" : "No"} />
<DetailRow label={t("adminSystemCanConnect")} value={status?.database.canConnect ? "Yes" : "No"} />
<DetailRow label={t("adminSystemUsesFileStorage")} value={status?.database.usesFileStorage ? "Yes" : "No"} />
<DetailRow label={t("adminSystemConfigured")} value={status?.database.looksConfigured ? t("yes") : t("noWord")} />
<DetailRow label={t("adminSystemCanConnect")} value={status?.database.canConnect ? t("yes") : t("noWord")} />
<DetailRow label={t("adminSystemUsesFileStorage")} value={status?.database.usesFileStorage ? t("yes") : t("noWord")} />
<DetailRow label={t("adminSystemDataRoot")} value={status?.storage.dataRoot || "-"} />
<DetailRow label={t("adminSystemDbPath")} value={status?.storage.dbPath || "-"} />
<DetailRow label={t("adminSystemDbFileExists")} value={status?.storage.dbExists ? "Yes" : "No"} />
<DetailRow label={t("adminSystemDbFileExists")} value={status?.storage.dbExists ? t("yes") : t("noWord")} />
<DetailRow label={t("adminSystemDbSize")} value={formatBytes(status?.storage.dbSizeBytes)} />
<DetailRow label="Companies" value={status?.storage.companyCount ?? 0} />
<DetailRow label={t("companies")} value={status?.storage.companyCount ?? 0} />
<DetailRow label={t("adminSystemJobs")} value={status?.storage.jobCount ?? 0} />
<DetailRow label={t("adminSystemDeletedJobs")} value={status?.storage.deletedCount ?? 0} />
</Stack>
@@ -271,10 +271,10 @@ export default function AdminSystemPage() {
<DetailRow label={t("adminSystemMachine")} value={status?.runtime.machineName || "-"} />
<DetailRow label={t("adminSystemContentRoot")} value={status?.contentRoot || "-"} />
<DetailRow label={t("adminSystemBuildStamp")} value={displayMetadata(status?.buildStamp)} />
<DetailRow label={t("adminSystemAuthRequired")} value={status?.auth.required ? "Yes" : "No"} />
<DetailRow label={t("adminSystemJwtConfigured")} value={status?.auth.hasJwtKey ? "Yes" : "No"} />
<DetailRow label={t("adminSystemGoogleConfigured")} value={status?.auth.googleConfigured ? "Yes" : "No"} />
<DetailRow label={t("adminSystemGmailConfigured")} value={status?.auth.gmailConfigured ? "Yes" : "No"} />
<DetailRow label={t("adminSystemAuthRequired")} value={status?.auth.required ? t("yes") : t("noWord")} />
<DetailRow label={t("adminSystemJwtConfigured")} value={status?.auth.hasJwtKey ? t("yes") : t("noWord")} />
<DetailRow label={t("adminSystemGoogleConfigured")} value={status?.auth.googleConfigured ? t("yes") : t("noWord")} />
<DetailRow label={t("adminSystemGmailConfigured")} value={status?.auth.gmailConfigured ? t("yes") : t("noWord")} />
</Stack>
</Paper>
</Box>
@@ -283,12 +283,12 @@ export default function AdminSystemPage() {
<Paper sx={{ p: 2, borderRadius: 3 }}>
<Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>{t("adminSystemEmailConfig")}</Typography>
<Stack spacing={0.75}>
<DetailRow label={t("adminSystemEnabled")} value={status?.email.enabled ? "Yes" : "No"} />
<DetailRow label={t("adminSystemEnabled")} value={status?.email.enabled ? t("yes") : t("noWord")} />
<DetailRow label={t("adminSystemFrom")} value={status?.email.from || "-"} />
<DetailRow label={t("adminSystemFromName")} value={status?.email.fromName || "-"} />
<DetailRow label={t("adminSystemHost")} value={status?.email.host || "-"} />
<DetailRow label={t("adminSystemPort")} value={status?.email.port ?? "-"} />
<DetailRow label={t("adminSystemSsl")} value={status?.email.enableSsl ? "Yes" : "No"} />
<DetailRow label={t("adminSystemSsl")} value={status?.email.enableSsl ? t("yes") : t("noWord")} />
</Stack>
</Paper>
@@ -297,7 +297,7 @@ export default function AdminSystemPage() {
<Stack spacing={0.75}>
<DetailRow label={t("adminSystemModel")} value={status?.ai.model || "-"} />
<DetailRow label={t("adminSystemDevice")} value={status?.ai.device || "-"} />
<DetailRow label={t("adminSystemGpuAvailable")} value={status?.ai.gpuAvailable ? "Yes" : "No"} />
<DetailRow label={t("adminSystemGpuAvailable")} value={status?.ai.gpuAvailable ? t("yes") : t("noWord")} />
<DetailRow label={t("adminSystemGpuName")} value={status?.ai.gpuName || "-"} />
<DetailRow label={t("adminSystemHealthLatency")} value={status?.ai.healthLatencyMs != null ? `${status.ai.healthLatencyMs} ms` : "-"} />
<DetailRow label={t("adminSystemProbeLatency")} value={status?.ai.probeLatencyMs != null ? `${status.ai.probeLatencyMs} ms` : "-"} />
+2 -2
View File
@@ -133,7 +133,7 @@ export default function AdminUsersPage() {
<TableRow>
<TableCell>{t("profileEmail")}</TableCell>
<TableCell>{t("profileUsername")}</TableCell>
<TableCell>Roles</TableCell>
<TableCell>{t("adminUsersRolesLabel")}</TableCell>
<TableCell>{t("adminUsersConfirmed")}</TableCell>
<TableCell align="right">{t("adminUsersActions")}</TableCell>
</TableRow>
@@ -146,7 +146,7 @@ export default function AdminUsersPage() {
<TableCell sx={{ fontWeight: 850 }}>{u.email || ""}</TableCell>
<TableCell sx={{ color: "text.secondary" }}>{u.userName || ""}</TableCell>
<TableCell sx={{ color: "text.secondary" }}>{u.roles?.length ? u.roles.join(", ") : "-"}</TableCell>
<TableCell sx={{ color: "text.secondary" }}>{u.emailConfirmed ? "Yes" : "No"}</TableCell>
<TableCell sx={{ color: "text.secondary" }}>{u.emailConfirmed ? t("yes") : t("noWord")}</TableCell>
<TableCell align="right">
<Box sx={{ display: "flex", justifyContent: "flex-end", gap: 1, flexWrap: "wrap" }}>
<Button size="small" variant={isAdmin ? "contained" : "outlined"} onClick={() => void setAdminRole(u, !isAdmin)}>