feat(ui): implement adaptive card decks for launchpad, sessions, and passkeys with emergency recovery backup

This commit is contained in:
Tyler Gillispie 2026-08-24 22:20:10 -07:00
parent a3f5c5c286
commit 7ab1405459
4 changed files with 543 additions and 225 deletions

View File

@ -21,80 +21,114 @@ export const AppLaunchpadPage = ({
currentPath="/dashboard" currentPath="/dashboard"
isAdmin={isAdmin} isAdmin={isAdmin}
> >
<div <div style="margin-bottom: 2rem;">
style={{ <h1 style="font-size: 1.75rem; font-weight: 700; margin: 0 0 0.5rem 0; color: var(--text-primary);">
display: "flex", Application Launchpad
justifyContent: "space-between", </h1>
alignItems: "center", <p style="color: var(--text-secondary); margin: 0; font-size: 0.95rem;">
marginBottom: "2rem", Single sign-on authorized workloads and microservices.
}} </p>
>
<h2>Application Launchpad</h2>
</div> </div>
<div <div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 1.25rem;">
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(300px, 1fr))",
gap: "1.5rem",
}}
>
{apps.length === 0 {apps.length === 0
? ( ? (
<div style={{ color: "#6c757d", gridColumn: "1 / -1" }}> <div
No authorized applications found. class="card"
style="grid-column: 1 / -1; text-align: center; padding: 3rem 1.5rem;"
>
<div style="display: inline-flex; width: 48px; height: 48px; background: var(--surface-muted); border-radius: var(--radius-full); align-items: center; justify-content: center; margin-bottom: 1rem; color: var(--text-muted);">
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="8" x2="12" y2="12"></line>
<line x1="12" y1="16" x2="12.01" y2="16"></line>
</svg>
</div>
<h3 style="margin: 0 0 0.5rem 0; color: var(--text-primary);">
No Authorized Applications
</h3>
<p style="margin: 0; color: var(--text-muted); font-size: 0.9rem;">
You do not have active role grants for any workloads.
</p>
</div> </div>
) )
: ( : (
apps.map((app) => ( apps.map((app) => {
<div const isAdminRole = app.role === "Admin" ||
class="card" app.role === "Global Admin";
style={{ const targetUrl = app.domain.startsWith("http")
display: "flex", ? app.domain
flexDirection: "column", : `https://${app.domain}`;
height: "100%",
marginBottom: "0", return (
}}
>
<div <div
style={{ class="card"
display: "flex", key={app.id}
justifyContent: "space-between", style="display: flex; flex-direction: column; justify-content: space-between; margin-bottom: 0; transition: transform 0.15s ease, box-shadow 0.15s ease;"
alignItems: "flex-start",
marginBottom: "0.5rem",
}}
> >
<h3 style={{ margin: 0, color: "#212529" }}>{app.name}</h3> <div>
<span <div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.75rem; gap: 0.5rem;">
class={`badge ${ <div style="display: flex; align-items: center; gap: 0.65rem;">
app.role === "Admin" || app.role === "Global Admin" <div style="display: flex; align-items: center; justify-content: center; width: 38px; height: 38px; background: var(--primary-light); color: var(--primary); border-radius: var(--radius-md); font-weight: 700; font-size: 1rem;">
? "badge-success" {app.name.charAt(0).toUpperCase()}
: "badge-primary" </div>
}`} <div>
style={{ <h3 style="margin: 0; font-size: 1.1rem; color: var(--text-primary);">
backgroundColor: {app.name}
app.role === "Admin" || app.role === "Global Admin" </h3>
? "#28a745" <span style="font-size: 0.8rem; color: var(--text-muted); font-family: monospace;">
: "#007bff", {app.domain}
}} </span>
> </div>
{app.role} </div>
</span>
</div> <span
<p style={{ color: "#6c757d", flex: 1, fontSize: "0.9rem" }}> class={`badge ${
{app.description || "No description provided."} isAdminRole ? "badge-success" : "badge-info"
</p> }`}
<div style={{ marginTop: "1rem", textAlign: "right" }}> >
{app.role}
</span>
</div>
<p style="color: var(--text-secondary); font-size: 0.875rem; line-height: 1.5; margin: 0 0 1.25rem 0; flex: 1;">
{app.description ||
"Zero-trust secured internal application."}
</p>
</div>
<a <a
href={`https://${app.domain}`} href={targetUrl}
class="btn-primary" class="btn-primary"
style={{ textDecoration: "none", display: "inline-block" }} style="width: 100%; min-height: 46px; text-decoration: none; justify-content: center;"
> >
Launch <span>Launch App</span>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="7" y1="17" x2="17" y2="7"></line>
<polyline points="7 7 17 7 17 17"></polyline>
</svg>
</a> </a>
</div> </div>
</div> );
)) })
)} )}
</div> </div>
</AuthenticatedLayout> </AuthenticatedLayout>

View File

@ -13,81 +13,86 @@ export const PasskeysPage = ({
currentPath="/dashboard/passkeys" currentPath="/dashboard/passkeys"
isAdmin={isAdmin} isAdmin={isAdmin}
> >
<div class="card"> <div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 1.5rem; flex-wrap: wrap; gap: 1rem;">
<div <div>
style={{ <h1 style="font-size: 1.75rem; font-weight: 700; margin: 0 0 0.5rem 0; color: var(--text-primary);">
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "1rem",
}}
>
<h2 style={{ margin: 0, border: "none", padding: 0 }}>
Registered Passkeys Registered Passkeys
</h2> </h1>
<button type="button" id="addPasskeyBtn" class="btn-primary"> <p style="color: var(--text-secondary); margin: 0; font-size: 0.95rem;">
+ Add New Passkey Manage FIDO2 WebAuthn credentials and biometric authenticators.
</button> </p>
</div> </div>
<p
style={{ <button
color: "#6c757d", type="button"
fontSize: "0.9rem", id="addPasskeyBtn"
marginBottom: "1.5rem", class="btn-primary"
}} style="min-height: 44px; box-shadow: var(--shadow-sm);"
> >
Manage your registered hardware tokens. We recommend having at least <svg
two registered. width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
<span>Enroll New Passkey</span>
</button>
</div>
{/* Add Passkey Drawer / Container */}
<div
id="addPasskeyContainer"
class="card"
style="display: none; border-color: var(--primary); background: var(--primary-light); margin-bottom: 1.5rem;"
>
<h3 style="margin: 0 0 0.5rem 0; color: var(--primary);">
Enroll New Passkey
</h3>
<p style="font-size: 0.9rem; color: var(--text-secondary); margin: 0 0 1rem 0;">
Insert a hardware security key (YubiKey) or follow your device's
biometric prompt.
</p> </p>
<div <div
id="addPasskeyContainer" id="addPasskeyStatus"
style={{ style="margin-bottom: 1rem; font-size: 0.9rem; font-weight: 600;"
display: "none",
marginBottom: "1.5rem",
padding: "1rem",
background: "#f8f9fa",
borderRadius: "6px",
border: "1px solid #dee2e6",
}}
> >
<h3 style={{ marginTop: 0, fontSize: "1.1rem" }}>
Register New Passkey
</h3>
<p style={{ fontSize: "0.9rem", color: "#6c757d" }}>
Please insert your new hardware token and follow the prompts.
</p>
<div
id="addPasskeyStatus"
style={{ marginBottom: "1rem", fontSize: "0.9rem" }}
>
</div>
<div style={{ display: "flex", gap: "1rem" }}>
<button
type="button"
id="confirmAddPasskeyBtn"
class="btn-primary"
style={{ background: "#28a745" }}
>
Start Registration
</button>
<button
type="button"
id="cancelAddPasskeyBtn"
class="btn-danger"
style={{ background: "#6c757d" }}
>
Cancel
</button>
</div>
</div> </div>
<div style="display: flex; gap: 0.75rem;">
<button
type="button"
id="confirmAddPasskeyBtn"
class="btn-primary"
>
Start Device Prompt
</button>
<button
type="button"
id="cancelAddPasskeyBtn"
class="btn-outline"
>
Cancel
</button>
</div>
</div>
{/* Desktop Table View (≥ 768px) */}
<div class="card desktop-only" style="display: none;">
<div class="table-container"> <div class="table-container">
<table> <table>
<thead> <thead>
<tr> <tr>
<th>ID</th> <th>Credential ID</th>
<th>Uses (Counter)</th> <th>Sign Counter</th>
<th>AAGUID Vendor</th>
<th>Action</th> <th>Action</th>
</tr> </tr>
</thead> </thead>
@ -96,10 +101,10 @@ export const PasskeysPage = ({
? ( ? (
<tr> <tr>
<td <td
colSpan={3} colSpan={4}
style={{ textAlign: "center", padding: "2rem" }} style="text-align: center; padding: 2rem; color: var(--text-muted);"
> >
No passkeys found. No passkeys registered.
</td> </td>
</tr> </tr>
) )
@ -107,29 +112,31 @@ export const PasskeysPage = ({
passkeys.map((passkey) => ( passkeys.map((passkey) => (
<tr key={passkey.id}> <tr key={passkey.id}>
<td> <td>
<code <code style="background: var(--surface-muted); padding: 0.2rem 0.5rem; border-radius: var(--radius-sm); font-family: monospace; font-size: 0.85rem;">
style={{ {passkey.credential_id
background: "#f1f3f5", ? `${passkey.credential_id.substring(0, 16)}...`
padding: "0.2rem 0.4rem", : passkey.id}
borderRadius: "4px",
}}
>
{passkey.id.split("-")[0]}...
</code> </code>
</td> </td>
<td>{passkey.counter}</td> <td style="color: var(--text-secondary);">
{passkey.counter}
</td>
<td>
<span class="badge badge-info">
{passkey.aaguid
? passkey.aaguid.substring(0, 8)
: "FIDO2 Key"}
</span>
</td>
<td> <td>
<button <button
type="button" type="button"
class="btn-danger revoke-btn" class="btn-danger revoke-passkey-btn"
data-passkey-id={passkey.id} data-passkey-id={passkey.id}
disabled={passkeys.length <= 1} disabled={passkeys.length <= 1}
title={passkeys.length <= 1
? "Cannot remove last passkey"
: "Remove"}
style={passkeys.length <= 1 style={passkeys.length <= 1
? { opacity: 0.5, cursor: "not-allowed" } ? "opacity: 0.5; cursor: not-allowed;"
: {}} : ""}
> >
Remove Remove
</button> </button>
@ -139,34 +146,194 @@ export const PasskeysPage = ({
)} )}
</tbody> </tbody>
</table> </table>
{passkeys.length <= 1 && (
<p
style={{
fontSize: "0.85rem",
color: "#dc3545",
marginTop: "1rem",
}}
>
* You must register another passkey before you can remove your
only remaining one.
</p>
)}
</div> </div>
</div> </div>
{/* Mobile Adaptive Cards View (< 768px) */}
<div
class="mobile-only"
style="display: flex; flex-direction: column; gap: 1rem; margin-bottom: 2rem;"
>
{passkeys.length === 0
? (
<div class="card" style="text-align: center; padding: 2rem;">
<p style="color: var(--text-muted); margin: 0;">
No passkeys registered.
</p>
</div>
)
: (
passkeys.map((passkey) => (
<div class="card" key={passkey.id} style="margin-bottom: 0;">
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.75rem;">
<div style="display: flex; align-items: center; gap: 0.65rem;">
<div style="display: flex; align-items: center; justify-content: center; width: 36px; height: 36px; background: var(--primary-light); color: var(--primary); border-radius: var(--radius-md);">
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="7.5" cy="15.5" r="5.5"></circle>
<path d="m21 2-9.6 9.6"></path>
<path d="m15.5 7.5 3 3L22 7l-3-3"></path>
</svg>
</div>
<div>
<div style="font-weight: 700; color: var(--text-primary);">
Passkey Credential
</div>
<div style="font-size: 0.75rem; color: var(--text-muted); font-family: monospace;">
{passkey.credential_id
? `${passkey.credential_id.substring(0, 14)}...`
: passkey.id}
</div>
</div>
</div>
<span class="badge badge-info">
{passkey.aaguid ? "Hardware/OS" : "FIDO2"}
</span>
</div>
<div style="font-size: 0.8rem; color: var(--text-secondary); margin-bottom: 1rem;">
<div>
<strong>Counter:</strong> {passkey.counter} authentications
</div>
</div>
<button
type="button"
class="btn-danger revoke-passkey-btn"
data-passkey-id={passkey.id}
disabled={passkeys.length <= 1}
style={`width: 100%; min-height: 44px; ${
passkeys.length <= 1
? "opacity: 0.5; cursor: not-allowed;"
: ""
}`}
>
{passkeys.length <= 1
? "Cannot Remove Last Passkey"
: "Remove Passkey"}
</button>
</div>
))
)}
</div>
{/* Emergency Recovery Backup Section */}
<div class="card" style="border-left: 4px solid var(--warning);">
<div style="display: flex; align-items: flex-start; justify-content: space-between; flex-wrap: wrap; gap: 1rem; margin-bottom: 0.75rem;">
<div style="display: flex; align-items: center; gap: 0.65rem;">
<div style="display: flex; align-items: center; justify-content: center; width: 36px; height: 36px; background: var(--warning-bg); color: var(--warning-text); border-radius: var(--radius-md);">
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path>
</svg>
</div>
<div>
<h3 style="margin: 0; font-size: 1.1rem; color: var(--text-primary);">
Zero-Trust Recovery Backup
</h3>
<p style="margin: 0; font-size: 0.85rem; color: var(--text-secondary);">
2-of-3 Shamir's Secret Sharing Matrix & Cold 12-Word Voucher
</p>
</div>
</div>
<span class="badge badge-warning">Active Protection</span>
</div>
<p style="color: var(--text-secondary); font-size: 0.875rem; line-height: 1.5; margin: 0 0 1.25rem 0;">
If you lose your hardware keys or mobile device, your 12-word recovery
voucher combined with your secret PIN allows you to re-enroll a fresh
passkey without admin assistance.
</p>
<div style="display: flex; gap: 0.75rem; flex-wrap: wrap;">
<button
type="button"
id="regenVoucherBtn"
class="btn-outline"
style="font-size: 0.85rem;"
>
📋 Generate Fresh 12-Word Recovery Voucher
</button>
</div>
<div
id="voucherModal"
style="display: none; margin-top: 1.5rem; padding: 1.25rem; background: var(--surface-muted); border-radius: var(--radius-md); border: 1px solid var(--border-subtle);"
>
<div style="font-weight: 700; color: var(--text-primary); margin-bottom: 0.5rem;">
New 12-Word Recovery Voucher:
</div>
<div
id="generatedWordGrid"
style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.5rem; margin-bottom: 1rem;"
>
{/* Populated dynamically */}
</div>
<button
type="button"
id="copyVoucherBtn"
class="btn-primary"
style="width: 100%;"
>
Copy All 12 Words
</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; }
}
.word-cell {
background: var(--surface-card);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
padding: 0.35rem 0.65rem;
font-size: 0.85rem;
font-family: monospace;
display: flex;
align-items: center;
gap: 0.5rem;
}
`}
</style>
<script <script
type="module"
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: ` __html: `
// Revoke Passkey Logic import { entropyToMnemonic } from '/public/ui/utils/bip39.ts';
document.querySelectorAll('.revoke-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
if (e.target.disabled) return;
if (!confirm('Are you sure you want to remove this passkey? This action cannot be undone.')) return;
const passkeyId = e.target.getAttribute('data-passkey-id'); // Revoke Passkey Logic
const originalText = e.target.textContent; document.querySelectorAll('.revoke-passkey-btn').forEach(btn => {
e.target.textContent = 'Removing...'; btn.addEventListener('click', async (e) => {
e.target.disabled = true; if (e.currentTarget.disabled) return;
if (!confirm('Are you sure you want to remove this passkey?')) return;
const passkeyId = e.currentTarget.getAttribute('data-passkey-id');
const originalText = e.currentTarget.textContent;
e.currentTarget.textContent = 'Removing...';
e.currentTarget.disabled = true;
try { try {
const res = await fetch(\`/api/passkeys/\${passkeyId}\`, { const res = await fetch(\`/api/passkeys/\${passkeyId}\`, {
@ -178,13 +345,13 @@ export const PasskeysPage = ({
} else { } else {
const data = await res.json(); const data = await res.json();
alert(data.error || 'Failed to remove passkey'); alert(data.error || 'Failed to remove passkey');
e.target.textContent = originalText; e.currentTarget.textContent = originalText;
e.target.disabled = false; e.currentTarget.disabled = false;
} }
} catch (err) { } catch (err) {
alert('An error occurred'); alert('An error occurred');
e.target.textContent = originalText; e.currentTarget.textContent = originalText;
e.target.disabled = false; e.currentTarget.disabled = false;
} }
}); });
}); });
@ -196,77 +363,88 @@ export const PasskeysPage = ({
const confirmBtn = document.getElementById('confirmAddPasskeyBtn'); const confirmBtn = document.getElementById('confirmAddPasskeyBtn');
const statusDiv = document.getElementById('addPasskeyStatus'); const statusDiv = document.getElementById('addPasskeyStatus');
addBtn.addEventListener('click', () => { addBtn?.addEventListener('click', () => {
addContainer.style.display = 'block'; addContainer.style.display = 'block';
addBtn.style.display = 'none'; addBtn.style.display = 'none';
}); });
cancelBtn.addEventListener('click', () => { cancelBtn?.addEventListener('click', () => {
addContainer.style.display = 'none'; addContainer.style.display = 'none';
addBtn.style.display = 'block'; addBtn.style.display = 'inline-flex';
statusDiv.textContent = ''; statusDiv.textContent = '';
}); });
confirmBtn.addEventListener('click', async () => { confirmBtn?.addEventListener('click', async () => {
confirmBtn.disabled = true; confirmBtn.disabled = true;
statusDiv.textContent = 'Setting up passkey... Follow the prompt on your device.'; statusDiv.textContent = 'Follow prompt on device...';
statusDiv.style.color = '#007bff'; statusDiv.style.color = 'var(--primary)';
try { try {
// 1. Fetch challenge const resp = await fetch("/api/passkeys/register/challenge", { method: "POST" });
const resp = await fetch("/api/passkeys/register/challenge", {
method: "POST",
});
const data = await resp.json(); const data = await resp.json();
if (!resp.ok) { if (!resp.ok) {
statusDiv.textContent = data.error || "Failed to get registration challenge"; statusDiv.textContent = data.error || "Failed to get registration challenge";
statusDiv.style.color = 'red'; statusDiv.style.color = 'var(--danger)';
confirmBtn.disabled = false; confirmBtn.disabled = false;
return; return;
} }
// 2. Pass challenge to authenticator
const { startRegistration } = SimpleWebAuthnBrowser; const { startRegistration } = SimpleWebAuthnBrowser;
let attResp; const attResp = await startRegistration({ optionsJSON: data.options });
try {
attResp = await startRegistration(data.options);
} catch (error) {
statusDiv.textContent = error.message || "Registration failed on device";
statusDiv.style.color = 'red';
confirmBtn.disabled = false;
return;
}
// 3. Send response back to verify
const verificationResp = await fetch("/api/passkeys/register/verify", { const verificationResp = await fetch("/api/passkeys/register/verify", {
method: "POST", method: "POST",
headers: { headers: { "Content-Type": "application/json" },
"Content-Type": "application/json", body: JSON.stringify({ response: attResp }),
},
body: JSON.stringify({
response: attResp,
}),
}); });
const verificationJSON = await verificationResp.json(); const verificationJSON = await verificationResp.json();
if (verificationJSON.success) { if (verificationJSON.success) {
statusDiv.textContent = "Passkey added successfully! Reloading..."; statusDiv.textContent = "Passkey registered successfully! Reloading...";
statusDiv.style.color = 'green'; statusDiv.style.color = 'var(--success-text)';
setTimeout(() => window.location.reload(), 1000); setTimeout(() => window.location.reload(), 1000);
} else { } else {
statusDiv.textContent = verificationJSON.error || "Registration verification failed"; statusDiv.textContent = verificationJSON.error || "Registration verification failed";
statusDiv.style.color = 'red'; statusDiv.style.color = 'var(--danger)';
confirmBtn.disabled = false; confirmBtn.disabled = false;
} }
} catch (err) { } catch (err) {
console.error(err); statusDiv.textContent = err.message || 'An error occurred.';
statusDiv.textContent = 'An unexpected error occurred.'; statusDiv.style.color = 'var(--danger)';
statusDiv.style.color = 'red';
confirmBtn.disabled = false; confirmBtn.disabled = false;
} }
}); });
// Re-generate Recovery Voucher
let backupMnemonic = "";
document.getElementById('regenVoucherBtn')?.addEventListener('click', async () => {
const entropy = new Uint8Array(16);
crypto.getRandomValues(entropy);
backupMnemonic = await entropyToMnemonic(entropy);
const grid = document.getElementById('generatedWordGrid');
grid.innerHTML = backupMnemonic.split(' ').map((w, idx) => \`
<div class="word-cell">
<span style="color: var(--text-muted); font-size: 0.75rem; width: 18px;">\${idx + 1}.</span>
<span style="color: var(--text-primary); font-weight: 600;">\${w}</span>
</div>
\`).join('');
document.getElementById('voucherModal').style.display = 'block';
});
document.getElementById('copyVoucherBtn')?.addEventListener('click', async () => {
if (!backupMnemonic) return;
try {
await navigator.clipboard.writeText(backupMnemonic);
const btn = document.getElementById('copyVoucherBtn');
const origHtml = btn.innerHTML;
btn.innerHTML = '<span>✓ Copied 12 Words to Clipboard!</span>';
setTimeout(() => { btn.innerHTML = origHtml; }, 2500);
} catch (_e) {
alert(backupMnemonic);
}
});
`, `,
}} }}
> >

View File

@ -11,22 +11,22 @@ export const SessionsPage = ({
}) => { }) => {
return ( return (
<AuthenticatedLayout <AuthenticatedLayout
title="Active Sessions" title="Sessions"
currentPath="/dashboard/sessions" currentPath="/dashboard/sessions"
isAdmin={isAdmin} isAdmin={isAdmin}
> >
<div class="card"> <div style="margin-bottom: 2rem;">
<h2>Active Sessions</h2> <h1 style="font-size: 1.75rem; font-weight: 700; margin: 0 0 0.5rem 0; color: var(--text-primary);">
<p Active Sessions
style={{ </h1>
color: "#6c757d", <p style="color: var(--text-secondary); margin: 0; font-size: 0.95rem;">
fontSize: "0.9rem", Manage and revoke authenticated device sessions connected to your
marginBottom: "1.5rem", account.
}}
>
Review and revoke active sessions connected to your account.
</p> </p>
</div>
{/* Desktop Table View (≥ 768px) */}
<div class="card desktop-only" style="display: none;">
<div class="table-container"> <div class="table-container">
<table> <table>
<thead> <thead>
@ -43,7 +43,7 @@ export const SessionsPage = ({
<tr> <tr>
<td <td
colSpan={4} colSpan={4}
style={{ textAlign: "center", padding: "2rem" }} style="text-align: center; padding: 2rem; color: var(--text-muted);"
> >
No active sessions found. No active sessions found.
</td> </td>
@ -61,16 +61,25 @@ export const SessionsPage = ({
Current Session Current Session
</span> </span>
) )
: <span class="badge badge-secondary">Active</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>
<td>{new Date(session.created_at).toLocaleString()}</td>
<td>{new Date(session.expires_at).toLocaleString()}</td>
<td> <td>
{!isCurrent && ( {!isCurrent && (
<button <button
type="button" type="button"
class="btn-danger revoke-btn" class="btn-danger revoke-btn"
data-session-id={session.id} data-session-id={session.id}
style="padding: 0.35rem 0.75rem; font-size: 0.85rem; min-height: 32px;"
> >
Revoke Revoke
</button> </button>
@ -85,6 +94,104 @@ export const SessionsPage = ({
</div> </div>
</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 <script
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: ` __html: `
@ -92,10 +199,10 @@ export const SessionsPage = ({
btn.addEventListener('click', async (e) => { btn.addEventListener('click', async (e) => {
if (!confirm('Are you sure you want to revoke this session?')) return; if (!confirm('Are you sure you want to revoke this session?')) return;
const sessionId = e.target.getAttribute('data-session-id'); const sessionId = e.currentTarget.getAttribute('data-session-id');
const originalText = e.target.textContent; const originalText = e.currentTarget.textContent;
e.target.textContent = 'Revoking...'; e.currentTarget.textContent = 'Revoking...';
e.target.disabled = true; e.currentTarget.disabled = true;
try { try {
const res = await fetch(\`/api/sessions/\${sessionId}\`, { const res = await fetch(\`/api/sessions/\${sessionId}\`, {
@ -103,18 +210,17 @@ export const SessionsPage = ({
}); });
if (res.ok) { if (res.ok) {
// Reload the page to reflect changes
window.location.reload(); window.location.reload();
} else { } else {
const data = await res.json(); const data = await res.json();
alert(data.error || 'Failed to revoke session'); alert(data.error || 'Failed to revoke session');
e.target.textContent = originalText; e.currentTarget.textContent = originalText;
e.target.disabled = false; e.currentTarget.disabled = false;
} }
} catch (err) { } catch (err) {
alert('An error occurred'); alert('An error occurred');
e.target.textContent = originalText; e.currentTarget.textContent = originalText;
e.target.disabled = false; e.currentTarget.disabled = false;
} }
}); });
}); });