Files
jobtrackingapp/job-tracker-ui/src/components/SessionsSettingsCard.tsx
T

159 lines
5.5 KiB
TypeScript

import React, { useCallback, useEffect, useState } from "react";
import {
Alert,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
IconButton,
List,
ListItem,
ListItemSecondaryAction,
ListItemText,
Typography,
} from "@mui/material";
import DeleteIcon from "@mui/icons-material/Delete";
import { api, getApiErrorMessage } from "../api";
import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider";
import { clearAuthClientState } from "../auth";
import { PageSection } from "../layout/PageLayout";
type Session = {
id: string;
deviceLabel: string | null;
createdAtUtc: string;
lastSeenAtUtc: string;
expiresAtUtc: string;
isCurrentSession: boolean;
};
function apiErrorMessage(e: any, t: (k: any) => string) {
return getApiErrorMessage(e, t("twoFactorGenericError"));
}
export default function SessionsSettingsCard() {
const { toast } = useToast();
const { t, language } = useI18n();
const locale = language === "nb" ? "nb-NO" : "en";
const [sessions, setSessions] = useState<Session[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [confirmRevokeOthers, setConfirmRevokeOthers] = useState(false);
const [confirmError, setConfirmError] = useState<string | null>(null);
const [revokingOthers, setRevokingOthers] = useState(false);
const loadSessions = useCallback(() => {
setLoading(true);
setError(null);
api
.get<Session[]>("/auth/sessions")
.then((r) => setSessions(r.data))
.catch((e) => setError(apiErrorMessage(e, t)))
.finally(() => setLoading(false));
}, [t]);
useEffect(() => { loadSessions(); }, [loadSessions]);
async function revokeSession(id: string, isCurrentSession: boolean) {
try {
await api.delete(`/auth/sessions/${id}`);
if (isCurrentSession) {
// Same pattern as AuthStatusCard's sign-out: clearing local auth state emits
// "auth-changed", which App.tsx's listener picks up to refetch /auth/me (now 401,
// since the server already deleted the session cookie) and redirect to /login.
clearAuthClientState();
return;
}
toast(t("sessionsRevoked"), "success");
loadSessions();
} catch (e: any) {
setError(apiErrorMessage(e, t));
}
}
async function revokeOthers() {
setRevokingOthers(true);
setConfirmError(null);
try {
await api.post("/auth/sessions/revoke-others");
toast(t("sessionsRevokedOthers"), "success");
setConfirmRevokeOthers(false);
loadSessions();
} catch (e: any) {
setConfirmError(apiErrorMessage(e, t));
} finally {
setRevokingOthers(false);
}
}
function closeConfirmation() {
setConfirmRevokeOthers(false);
setConfirmError(null);
}
return (
<PageSection title={t("sessionsSectionTitle")} surface>
{loading ? <Typography role="status" color="text.secondary">{t("loading")}</Typography> : null}
{error ? <Alert severity="error" role="alert" sx={{ mb: 1 }} action={<Button color="inherit" onClick={loadSessions}>{t("retry")}</Button>}>{error}</Alert> : null}
{!loading && sessions.length === 0 && !error ? (
<Typography sx={{ color: "text.secondary", mb: 1 }}>{t("sessionsEmpty")}</Typography>
) : null}
{sessions.length > 0 ? (
<List dense disablePadding>
{sessions.map((s) => (
<ListItem key={s.id} divider>
<ListItemText
sx={{ minWidth: 0, overflowWrap: "anywhere" }}
primary={
<>
{s.deviceLabel || t("sessionsUnknownDevice")}
{s.isCurrentSession ? (
<Typography component="span" variant="caption" sx={{ ml: 1, color: "primary.main", fontWeight: 700 }}>
{t("sessionsCurrentDevice")}
</Typography>
) : null}
</>
}
secondary={t("sessionsMeta", {
lastSeen: new Date(s.lastSeenAtUtc).toLocaleString(locale),
expires: new Date(s.expiresAtUtc).toLocaleDateString(locale),
})}
/>
<ListItemSecondaryAction>
<IconButton edge="end" aria-label={t("sessionsRevoke")} onClick={() => revokeSession(s.id, s.isCurrentSession)}>
<DeleteIcon fontSize="small" />
</IconButton>
</ListItemSecondaryAction>
</ListItem>
))}
</List>
) : null}
{sessions.length > 1 ? (
<Button variant="text" color="warning" size="small" sx={{ mt: 1 }} onClick={() => { setConfirmError(null); setConfirmRevokeOthers(true); }}>
{t("sessionsRevokeOthers")}
</Button>
) : null}
<Dialog open={confirmRevokeOthers} onClose={revokingOthers ? undefined : closeConfirmation} maxWidth="sm" fullWidth>
<DialogTitle>{t("sessionsRevokeOthersConfirmTitle")}</DialogTitle>
<DialogContent>
{confirmError ? <Alert severity="error" sx={{ mb: 2 }}>{confirmError}</Alert> : null}
<Typography>{t("sessionsRevokeOthersConfirmBody")}</Typography>
</DialogContent>
<DialogActions>
<Button type="button" onClick={closeConfirmation} disabled={revokingOthers}>{t("cancel")}</Button>
<Button variant="contained" color="warning" onClick={revokeOthers} disabled={revokingOthers}>
{t("sessionsRevokeOthers")}
</Button>
</DialogActions>
</Dialog>
</PageSection>
);
}