Localize kanban, auth, backup, and admin utilities
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
))
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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` : "-"} />
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
Reference in New Issue
Block a user