feat(ui): implement adaptive card decks for launchpad, sessions, and passkeys with emergency recovery backup
This commit is contained in:
parent
a3f5c5c286
commit
7ab1405459
@ -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) => {
|
||||||
|
const isAdminRole = app.role === "Admin" ||
|
||||||
|
app.role === "Global Admin";
|
||||||
|
const targetUrl = app.domain.startsWith("http")
|
||||||
|
? app.domain
|
||||||
|
: `https://${app.domain}`;
|
||||||
|
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
class="card"
|
class="card"
|
||||||
style={{
|
key={app.id}
|
||||||
display: "flex",
|
style="display: flex; flex-direction: column; justify-content: space-between; margin-bottom: 0; transition: transform 0.15s ease, box-shadow 0.15s ease;"
|
||||||
flexDirection: "column",
|
|
||||||
height: "100%",
|
|
||||||
marginBottom: "0",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div
|
<div>
|
||||||
style={{
|
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.75rem; gap: 0.5rem;">
|
||||||
display: "flex",
|
<div style="display: flex; align-items: center; gap: 0.65rem;">
|
||||||
justifyContent: "space-between",
|
<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;">
|
||||||
alignItems: "flex-start",
|
{app.name.charAt(0).toUpperCase()}
|
||||||
marginBottom: "0.5rem",
|
</div>
|
||||||
}}
|
<div>
|
||||||
>
|
<h3 style="margin: 0; font-size: 1.1rem; color: var(--text-primary);">
|
||||||
<h3 style={{ margin: 0, color: "#212529" }}>{app.name}</h3>
|
{app.name}
|
||||||
|
</h3>
|
||||||
|
<span style="font-size: 0.8rem; color: var(--text-muted); font-family: monospace;">
|
||||||
|
{app.domain}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<span
|
<span
|
||||||
class={`badge ${
|
class={`badge ${
|
||||||
app.role === "Admin" || app.role === "Global Admin"
|
isAdminRole ? "badge-success" : "badge-info"
|
||||||
? "badge-success"
|
|
||||||
: "badge-primary"
|
|
||||||
}`}
|
}`}
|
||||||
style={{
|
|
||||||
backgroundColor:
|
|
||||||
app.role === "Admin" || app.role === "Global Admin"
|
|
||||||
? "#28a745"
|
|
||||||
: "#007bff",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{app.role}
|
{app.role}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p style={{ color: "#6c757d", flex: 1, fontSize: "0.9rem" }}>
|
|
||||||
{app.description || "No description provided."}
|
<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>
|
</p>
|
||||||
<div style={{ marginTop: "1rem", textAlign: "right" }}>
|
</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>
|
||||||
|
|||||||
@ -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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id="addPasskeyBtn"
|
||||||
|
class="btn-primary"
|
||||||
|
style="min-height: 44px; box-shadow: var(--shadow-sm);"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p
|
|
||||||
style={{
|
{/* Add Passkey Drawer / Container */}
|
||||||
color: "#6c757d",
|
<div
|
||||||
fontSize: "0.9rem",
|
id="addPasskeyContainer"
|
||||||
marginBottom: "1.5rem",
|
class="card"
|
||||||
}}
|
style="display: none; border-color: var(--primary); background: var(--primary-light); margin-bottom: 1.5rem;"
|
||||||
>
|
>
|
||||||
Manage your registered hardware tokens. We recommend having at least
|
<h3 style="margin: 0 0 0.5rem 0; color: var(--primary);">
|
||||||
two registered.
|
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
|
|
||||||
id="addPasskeyContainer"
|
|
||||||
style={{
|
|
||||||
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
|
<div
|
||||||
id="addPasskeyStatus"
|
id="addPasskeyStatus"
|
||||||
style={{ marginBottom: "1rem", fontSize: "0.9rem" }}
|
style="margin-bottom: 1rem; font-size: 0.9rem; font-weight: 600;"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: "flex", gap: "1rem" }}>
|
|
||||||
|
<div style="display: flex; gap: 0.75rem;">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
id="confirmAddPasskeyBtn"
|
id="confirmAddPasskeyBtn"
|
||||||
class="btn-primary"
|
class="btn-primary"
|
||||||
style={{ background: "#28a745" }}
|
|
||||||
>
|
>
|
||||||
Start Registration
|
Start Device Prompt
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
id="cancelAddPasskeyBtn"
|
id="cancelAddPasskeyBtn"
|
||||||
class="btn-danger"
|
class="btn-outline"
|
||||||
style={{ background: "#6c757d" }}
|
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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 && (
|
</div>
|
||||||
<p
|
</div>
|
||||||
style={{
|
|
||||||
fontSize: "0.85rem",
|
{/* Mobile Adaptive Cards View (< 768px) */}
|
||||||
color: "#dc3545",
|
<div
|
||||||
marginTop: "1rem",
|
class="mobile-only"
|
||||||
}}
|
style="display: flex; flex-direction: column; gap: 1rem; margin-bottom: 2rem;"
|
||||||
>
|
>
|
||||||
* You must register another passkey before you can remove your
|
{passkeys.length === 0
|
||||||
only remaining one.
|
? (
|
||||||
|
<div class="card" style="text-align: center; padding: 2rem;">
|
||||||
|
<p style="color: var(--text-muted); margin: 0;">
|
||||||
|
No passkeys registered.
|
||||||
</p>
|
</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>
|
</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>
|
</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);
|
||||||
|
}
|
||||||
|
});
|
||||||
`,
|
`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user