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>
|
||||
))
|
||||
|
||||
Reference in New Issue
Block a user