234 lines
8.4 KiB
TypeScript
234 lines
8.4 KiB
TypeScript
import { AuthenticatedLayout } from "./AuthenticatedLayout.tsx";
|
|
|
|
export const SessionsPage = ({
|
|
sessions,
|
|
currentSessionId,
|
|
isAdmin = false,
|
|
}: {
|
|
sessions: any[];
|
|
currentSessionId: string;
|
|
isAdmin?: boolean;
|
|
}) => {
|
|
return (
|
|
<AuthenticatedLayout
|
|
title="Sessions"
|
|
currentPath="/dashboard/sessions"
|
|
isAdmin={isAdmin}
|
|
>
|
|
<div style="margin-bottom: 2rem;">
|
|
<h1 style="font-size: 1.75rem; font-weight: 700; margin: 0 0 0.5rem 0; color: var(--text-primary);">
|
|
Active Sessions
|
|
</h1>
|
|
<p style="color: var(--text-secondary); margin: 0; font-size: 0.95rem;">
|
|
Manage and revoke authenticated device sessions connected to your
|
|
account.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Desktop Table View (≥ 768px) */}
|
|
<div class="card desktop-only" style="display: none;">
|
|
<div class="table-container">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Status</th>
|
|
<th>Created At</th>
|
|
<th>Expires At</th>
|
|
<th>Action</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{sessions.length === 0
|
|
? (
|
|
<tr>
|
|
<td
|
|
colSpan={4}
|
|
style="text-align: center; padding: 2rem; color: var(--text-muted);"
|
|
>
|
|
No active sessions found.
|
|
</td>
|
|
</tr>
|
|
)
|
|
: (
|
|
sessions.map((session) => {
|
|
const isCurrent = session.id === currentSessionId;
|
|
return (
|
|
<tr key={session.id}>
|
|
<td>
|
|
{isCurrent
|
|
? (
|
|
<span class="badge badge-success">
|
|
Current Session
|
|
</span>
|
|
)
|
|
: (
|
|
<span class="badge badge-secondary">
|
|
Remote Session
|
|
</span>
|
|
)}
|
|
</td>
|
|
<td style="color: var(--text-secondary);">
|
|
{new Date(session.created_at).toLocaleString()}
|
|
</td>
|
|
<td style="color: var(--text-secondary);">
|
|
{new Date(session.expires_at).toLocaleString()}
|
|
</td>
|
|
<td>
|
|
{!isCurrent && (
|
|
<button
|
|
type="button"
|
|
class="btn-danger revoke-btn"
|
|
data-session-id={session.id}
|
|
style="padding: 0.35rem 0.75rem; font-size: 0.85rem; min-height: 32px;"
|
|
>
|
|
Revoke
|
|
</button>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
);
|
|
})
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Mobile Adaptive Cards View (< 768px) */}
|
|
<div
|
|
class="mobile-only"
|
|
style="display: flex; flex-direction: column; gap: 1rem;"
|
|
>
|
|
{sessions.length === 0
|
|
? (
|
|
<div class="card" style="text-align: center; padding: 2rem;">
|
|
<p style="color: var(--text-muted); margin: 0;">
|
|
No active sessions found.
|
|
</p>
|
|
</div>
|
|
)
|
|
: (
|
|
sessions.map((session) => {
|
|
const isCurrent = session.id === currentSessionId;
|
|
return (
|
|
<div class="card" key={session.id} style="margin-bottom: 0;">
|
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem;">
|
|
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
|
<div style="display: flex; align-items: center; justify-content: center; width: 36px; height: 36px; background: var(--surface-muted); border-radius: var(--radius-md); color: var(--text-secondary);">
|
|
<svg
|
|
width="20"
|
|
height="20"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
>
|
|
<rect
|
|
width="14"
|
|
height="20"
|
|
x="5"
|
|
y="2"
|
|
rx="2"
|
|
ry="2"
|
|
>
|
|
</rect>
|
|
<line x1="12" x2="12.01" y1="18" y2="18"></line>
|
|
</svg>
|
|
</div>
|
|
<div>
|
|
<div style="font-weight: 600; font-size: 0.95rem; color: var(--text-primary);">
|
|
{isCurrent ? "This Device" : "Remote Device"}
|
|
</div>
|
|
<div style="font-size: 0.75rem; color: var(--text-muted); font-family: monospace;">
|
|
{session.id.substring(0, 13)}...
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{isCurrent
|
|
? <span class="badge badge-success">Active Now</span>
|
|
: <span class="badge badge-secondary">Active</span>}
|
|
</div>
|
|
|
|
<div style="font-size: 0.8rem; color: var(--text-secondary); margin-bottom: 1rem; line-height: 1.6;">
|
|
<div>
|
|
<strong>Signed in:</strong>{" "}
|
|
{new Date(session.created_at).toLocaleString()}
|
|
</div>
|
|
<div>
|
|
<strong>Expires:</strong>{" "}
|
|
{new Date(session.expires_at).toLocaleString()}
|
|
</div>
|
|
</div>
|
|
|
|
{!isCurrent && (
|
|
<button
|
|
type="button"
|
|
class="btn-danger revoke-btn"
|
|
data-session-id={session.id}
|
|
style="width: 100%; min-height: 44px;"
|
|
>
|
|
Revoke Device Session
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
|
|
<style>
|
|
{`
|
|
@media (min-width: 768px) {
|
|
.desktop-only { display: block !important; }
|
|
.mobile-only { display: none !important; }
|
|
}
|
|
@media (max-width: 767px) {
|
|
.desktop-only { display: none !important; }
|
|
.mobile-only { display: flex !important; }
|
|
}
|
|
`}
|
|
</style>
|
|
|
|
<script
|
|
dangerouslySetInnerHTML={{
|
|
__html: `
|
|
document.querySelectorAll('.revoke-btn').forEach(btn => {
|
|
btn.addEventListener('click', async (e) => {
|
|
if (!confirm('Are you sure you want to revoke this session?')) return;
|
|
|
|
const sessionId = e.currentTarget.getAttribute('data-session-id');
|
|
const originalText = e.currentTarget.textContent;
|
|
e.currentTarget.textContent = 'Revoking...';
|
|
e.currentTarget.disabled = true;
|
|
|
|
try {
|
|
const res = await fetch(\`/api/sessions/\${sessionId}\`, {
|
|
method: 'DELETE'
|
|
});
|
|
|
|
if (res.ok) {
|
|
window.location.reload();
|
|
} else {
|
|
const data = await res.json();
|
|
alert(data.error || 'Failed to revoke session');
|
|
e.currentTarget.textContent = originalText;
|
|
e.currentTarget.disabled = false;
|
|
}
|
|
} catch (err) {
|
|
alert('An error occurred');
|
|
e.currentTarget.textContent = originalText;
|
|
e.currentTarget.disabled = false;
|
|
}
|
|
});
|
|
});
|
|
`,
|
|
}}
|
|
>
|
|
</script>
|
|
</AuthenticatedLayout>
|
|
);
|
|
};
|