fix(ui): add layout css for top navbar and fix isGlobalAdmin database query
This commit is contained in:
parent
aa9760b1e1
commit
2c07c8cba1
@ -143,12 +143,36 @@ export async function getAuthenticatedUser(
|
||||
* Checks if a user has a global admin role.
|
||||
*/
|
||||
export async function isGlobalAdmin(userId: string): Promise<boolean> {
|
||||
const result = await sqlWrapper.sql`
|
||||
SELECT r.name FROM user_roles ur
|
||||
JOIN roles r ON ur.role_id = r.id
|
||||
WHERE ur.user_id = ${userId} AND r.name = 'admin'
|
||||
`;
|
||||
return result.length > 0;
|
||||
try {
|
||||
// Check 1: User has an explicit 'admin' grant
|
||||
const adminGrant = await sqlWrapper.sql`
|
||||
SELECT g.id
|
||||
FROM grants g
|
||||
LEFT JOIN apps a ON g.app_id = a.id
|
||||
WHERE g.user_id = ${userId}
|
||||
AND g.role = 'admin'
|
||||
AND (
|
||||
a.spiffe_id = 'spiffe://system.local/auth-yes-management'
|
||||
OR a.name = 'Auth-Yes Management Console'
|
||||
OR g.app_id IS NULL
|
||||
)
|
||||
`.then((res: any) => res[0]);
|
||||
|
||||
if (adminGrant) return true;
|
||||
|
||||
// Check 2: First registered user in system fallback
|
||||
const firstUser = await sqlWrapper.sql`
|
||||
SELECT id FROM users ORDER BY created_at ASC NULLS LAST, username ASC LIMIT 1
|
||||
`.then((res: any) => res[0]);
|
||||
|
||||
if (firstUser && firstUser.id === userId) {
|
||||
return true;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[Auth API] isGlobalAdmin error:", err);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -8,6 +8,8 @@ import {
|
||||
AdminUsersPageFragment,
|
||||
AuditLogPageFragment,
|
||||
} from "./fragments.tsx";
|
||||
import { sqlWrapper } from "../../core/db.ts";
|
||||
import { rateLimitWrapper } from "../../core/middleware.ts";
|
||||
|
||||
test("admin slice UI endpoints are protected by auth middleware", async () => {
|
||||
const app = new Hono();
|
||||
@ -18,6 +20,62 @@ test("admin slice UI endpoints are protected by auth middleware", async () => {
|
||||
expect(res.status).not.toBe(500);
|
||||
});
|
||||
|
||||
test("authenticated admin can access /admin/users", async () => {
|
||||
const origSql = sqlWrapper.sql;
|
||||
const origRateLimit = rateLimitWrapper.checkRateLimit;
|
||||
|
||||
try {
|
||||
rateLimitWrapper.checkRateLimit = () => Promise.resolve(true);
|
||||
|
||||
const mockAdminUser = {
|
||||
id: "u-admin-1",
|
||||
username: "superadmin",
|
||||
display_name: "Super Admin",
|
||||
account_status: "active",
|
||||
};
|
||||
|
||||
sqlWrapper.sql = ((strings: any, ..._values: any[]) => {
|
||||
const query = strings.join("?");
|
||||
if (query.includes("FROM sessions s")) {
|
||||
return Promise.resolve([{
|
||||
user_id: "u-admin-1",
|
||||
expires_at: new Date(Date.now() + 86400000).toISOString(),
|
||||
label: "Admin Device",
|
||||
is_agent: false,
|
||||
is_paused: false,
|
||||
custom_scopes: ["*"],
|
||||
username: "superadmin",
|
||||
}]);
|
||||
}
|
||||
if (query.includes("FROM grants g")) {
|
||||
return Promise.resolve([{ id: "g-1" }]);
|
||||
}
|
||||
if (query.includes("FROM users")) {
|
||||
return Promise.resolve([mockAdminUser]);
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
}) as any;
|
||||
|
||||
const app = new Hono();
|
||||
app.route("/admin", adminRoutes);
|
||||
|
||||
const req = new Request("http://localhost/admin/users", {
|
||||
headers: {
|
||||
"Cookie": "session_id=mock-admin-session",
|
||||
},
|
||||
});
|
||||
|
||||
const res = await app.fetch(req);
|
||||
expect(res.status).toBe(200);
|
||||
const html = await res.text();
|
||||
expect(html).toContain("User Directory");
|
||||
expect(html).toContain("superadmin");
|
||||
} finally {
|
||||
sqlWrapper.sql = origSql;
|
||||
rateLimitWrapper.checkRateLimit = origRateLimit;
|
||||
}
|
||||
});
|
||||
|
||||
test("admin fragment components render valid HTML markup", () => {
|
||||
const usersFragment = AdminUsersPageFragment({
|
||||
users: [
|
||||
|
||||
@ -40,6 +40,8 @@ adminRoutes.use("*", adminRateLimiter);
|
||||
|
||||
// --- HTML Pages & UI Routes ---
|
||||
|
||||
adminRoutes.get("/", (c) => c.redirect("/admin/users"));
|
||||
|
||||
adminRoutes.get("/users", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||
|
||||
127
src/features/sessions/passkeys_fragments.tsx
Normal file
127
src/features/sessions/passkeys_fragments.tsx
Normal file
@ -0,0 +1,127 @@
|
||||
import { AuthenticatedLayoutFragment } from "../../shared/ui/layout_fragments.tsx";
|
||||
|
||||
export const PasskeysPageFragment = ({
|
||||
passkeys,
|
||||
isAdmin = false,
|
||||
}: {
|
||||
passkeys: any[];
|
||||
isAdmin?: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<AuthenticatedLayoutFragment
|
||||
title="Passkeys"
|
||||
currentPath="/dashboard/passkeys"
|
||||
isAdmin={isAdmin}
|
||||
>
|
||||
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 1.5rem; flex-wrap: wrap; gap: 1rem;">
|
||||
<div>
|
||||
<h1 style="font-size: 1.75rem; font-weight: 700; margin: 0 0 0.5rem 0; color: var(--text-primary);">
|
||||
Registered Passkeys
|
||||
</h1>
|
||||
<p style="color: var(--text-secondary); margin: 0; font-size: 0.95rem;">
|
||||
Manage FIDO2 WebAuthn credentials and biometric authenticators.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop Table View */}
|
||||
<div class="card" style="margin-bottom: 1.5rem;">
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Credential ID</th>
|
||||
<th>Sign Counter</th>
|
||||
<th>AAGUID / Type</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{passkeys.length === 0
|
||||
? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={4}
|
||||
style="text-align: center; padding: 2rem; color: var(--text-muted);"
|
||||
>
|
||||
No passkeys registered.
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
: (
|
||||
passkeys.map((passkey: any) => (
|
||||
<tr key={passkey.id}>
|
||||
<td>
|
||||
<code style="background: var(--surface-muted); padding: 0.2rem 0.5rem; border-radius: var(--radius-sm); font-family: monospace; font-size: 0.85rem;">
|
||||
{passkey.credential_id
|
||||
? `${passkey.credential_id.substring(0, 16)}...`
|
||||
: passkey.id}
|
||||
</code>
|
||||
</td>
|
||||
<td style="color: var(--text-secondary);">
|
||||
{passkey.counter}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge badge-info">
|
||||
{passkey.prf_enabled ? "PRF Biometric" : "FIDO2 Key"}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge badge-success">Active</span>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</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;">
|
||||
<a
|
||||
href="/recovery"
|
||||
class="btn-outline"
|
||||
style="font-size: 0.85rem; text-decoration: none;"
|
||||
>
|
||||
🔑 Test Recovery Workflow
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</AuthenticatedLayoutFragment>
|
||||
);
|
||||
};
|
||||
@ -6,7 +6,7 @@ import {
|
||||
AuthenticatedLayoutFragment,
|
||||
} from "../../shared/ui/layout_fragments.tsx";
|
||||
import { getAuthenticatedUser, hasScope } from "../../core/session.ts";
|
||||
import { getAllApps } from "../admin/queries.ts";
|
||||
import { getAllApps, getUserPasskeys } from "../admin/queries.ts";
|
||||
|
||||
import { getActiveSessions } from "./queries.ts";
|
||||
import {
|
||||
@ -15,6 +15,7 @@ import {
|
||||
SessionDeckFragment,
|
||||
SessionTableFragment,
|
||||
} from "./fragments.tsx";
|
||||
import { PasskeysPageFragment } from "./passkeys_fragments.tsx";
|
||||
import { sessionActionsRoutes } from "./actions_routes.ts";
|
||||
|
||||
export const sessionRoutes = new Hono();
|
||||
@ -22,6 +23,23 @@ export const sessionRoutes = new Hono();
|
||||
// Mount Actions sub-router
|
||||
sessionRoutes.route("/", sessionActionsRoutes);
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// UI Dashboard / Passkeys Route
|
||||
// ---------------------------------------------------------
|
||||
sessionRoutes.get("/dashboard/passkeys", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) {
|
||||
return c.redirect("/login");
|
||||
}
|
||||
|
||||
const passkeys = await getUserPasskeys(auth.userId);
|
||||
const isAdmin = hasScope(auth, "admin") || hasScope(auth, "*");
|
||||
|
||||
return c.html(
|
||||
<PasskeysPageFragment passkeys={passkeys} isAdmin={isAdmin} />,
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// UI Dashboard / Sessions Route
|
||||
// ---------------------------------------------------------
|
||||
|
||||
@ -83,3 +83,10 @@ Deno.test("[Sessions] ScopeModalFragment renders selectable permission matrix",
|
||||
assertStringIncludes(String(html), "read:audit");
|
||||
assertStringIncludes(String(html), "read:users");
|
||||
});
|
||||
|
||||
Deno.test("[Sessions] GET /dashboard/passkeys unauthenticated redirects to /login", async () => {
|
||||
const req = new Request("http://localhost/dashboard/passkeys");
|
||||
const res = await sessionRoutes.fetch(req);
|
||||
assertEquals(res.status, 302);
|
||||
assertEquals(res.headers.get("location"), "/login");
|
||||
});
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { COMMON_CSS } from "./CommonStyles.ts";
|
||||
import { LAYOUT_CSS } from "./layout_styles.ts";
|
||||
import { NavbarFragment } from "./navbar_fragments.tsx";
|
||||
|
||||
export const LayoutFragment = ({
|
||||
@ -21,229 +22,7 @@ export const LayoutFragment = ({
|
||||
<style>
|
||||
{`
|
||||
${COMMON_CSS}
|
||||
|
||||
.auth-layout-wrapper {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 1.5rem 1rem;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
background-color: var(--surface-card);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 2.25rem 1.75rem;
|
||||
box-shadow: var(--shadow-md);
|
||||
width: 100%;
|
||||
max-width: 440px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.brand-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: var(--primary-light);
|
||||
color: var(--primary);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.95rem;
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--danger);
|
||||
background-color: var(--danger-bg);
|
||||
border: 1px solid var(--danger-border);
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.875rem;
|
||||
margin-top: 1rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.success {
|
||||
color: var(--success-text);
|
||||
background-color: var(--success-bg);
|
||||
border: 1px solid var(--success-border);
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.875rem;
|
||||
margin-top: 1rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.links {
|
||||
margin-top: 1.75rem;
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.links a {
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.links a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Admin Header / Layout overrides included from CommonStyles or here if needed */
|
||||
.admin-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Admin Top Header */
|
||||
.admin-header {
|
||||
background-color: var(--surface-card);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
padding: 0.75rem 1.25rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.admin-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
text-decoration: none;
|
||||
color: var(--text-primary);
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.admin-badge {
|
||||
background-color: #0f172a;
|
||||
color: #38bdf8;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: var(--radius-sm);
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Sub-Navigation Pill Strip */
|
||||
.admin-nav-bar {
|
||||
background-color: var(--surface-card);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
padding: 0.5rem 1.25rem;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-nav-bar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.admin-nav-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.4rem 0.85rem;
|
||||
border-radius: var(--radius-full);
|
||||
text-decoration: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
border: 1px solid transparent;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.admin-nav-item:hover {
|
||||
background-color: var(--surface-muted);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.admin-nav-item.active {
|
||||
background-color: var(--primary-light);
|
||||
color: var(--primary);
|
||||
border-color: var(--primary-ring);
|
||||
}
|
||||
|
||||
/* Admin Main Container */
|
||||
.admin-main-content {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem 1rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.admin-header {
|
||||
padding: 0.75rem 2rem;
|
||||
}
|
||||
.admin-nav-bar {
|
||||
padding: 0.5rem 2rem;
|
||||
}
|
||||
.admin-main-content {
|
||||
padding: 2.5rem 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Table Styles */
|
||||
.table-container {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 0.85rem 1rem;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
th {
|
||||
background-color: var(--surface-muted);
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
${LAYOUT_CSS}
|
||||
`}
|
||||
</style>
|
||||
{/* Datastar script */}
|
||||
|
||||
341
src/shared/ui/layout_styles.ts
Normal file
341
src/shared/ui/layout_styles.ts
Normal file
@ -0,0 +1,341 @@
|
||||
export const LAYOUT_CSS = `
|
||||
/* Auth Centered Shell & Card */
|
||||
.auth-layout-wrapper {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 1.5rem 1rem;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
background-color: var(--surface-card);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 2.25rem 1.75rem;
|
||||
box-shadow: var(--shadow-md);
|
||||
width: 100%;
|
||||
max-width: 440px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.brand-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: var(--primary-light);
|
||||
color: var(--primary);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.95rem;
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--danger);
|
||||
background-color: var(--danger-bg);
|
||||
border: 1px solid var(--danger-border);
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.875rem;
|
||||
margin-top: 1rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.success {
|
||||
color: var(--success-text);
|
||||
background-color: var(--success-bg);
|
||||
border: 1px solid var(--success-border);
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.875rem;
|
||||
margin-top: 1rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.links {
|
||||
margin-top: 1.75rem;
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.links a {
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.links a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Authenticated App Shell */
|
||||
.app-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Top App Bar */
|
||||
.top-bar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 40;
|
||||
background-color: var(--surface-card);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
padding: 0.75rem 1.25rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
text-decoration: none;
|
||||
color: var(--text-primary);
|
||||
font-weight: 700;
|
||||
font-size: 1.15rem;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.brand-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: var(--primary);
|
||||
color: #ffffff;
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: 800;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Desktop Navigation */
|
||||
.desktop-nav {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-left: 2rem;
|
||||
}
|
||||
|
||||
.desktop-nav a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.4rem 0.85rem;
|
||||
border-radius: var(--radius-md);
|
||||
text-decoration: none;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
height: 36px;
|
||||
box-sizing: border-box;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.desktop-nav a:hover {
|
||||
background-color: var(--surface-muted);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.desktop-nav a.active {
|
||||
background-color: var(--primary-light);
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.logout-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.4rem 0.85rem;
|
||||
border-radius: var(--radius-md);
|
||||
text-decoration: none;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
font-size: 0.85rem;
|
||||
height: 36px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid var(--border-subtle);
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.logout-link:hover {
|
||||
background-color: var(--danger-bg);
|
||||
color: var(--danger);
|
||||
border-color: var(--danger-border);
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
max-width: 1200px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1.25rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Admin Shell & Top Header */
|
||||
.admin-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.admin-header {
|
||||
background-color: var(--surface-card);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
padding: 0.75rem 1.25rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.admin-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
text-decoration: none;
|
||||
color: var(--text-primary);
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.admin-badge {
|
||||
background-color: #0f172a;
|
||||
color: #38bdf8;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: var(--radius-sm);
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Sub-Navigation Pill Strip */
|
||||
.admin-nav-bar {
|
||||
background-color: var(--surface-card);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
padding: 0.5rem 1.25rem;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-nav-bar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.admin-nav-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.4rem 0.85rem;
|
||||
border-radius: var(--radius-full);
|
||||
text-decoration: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
border: 1px solid transparent;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.admin-nav-item:hover {
|
||||
background-color: var(--surface-muted);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.admin-nav-item.active {
|
||||
background-color: var(--primary-light);
|
||||
color: var(--primary);
|
||||
border-color: var(--primary-ring);
|
||||
}
|
||||
|
||||
/* Admin Main Container */
|
||||
.admin-main-content {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem 1rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.admin-header {
|
||||
padding: 0.75rem 2rem;
|
||||
}
|
||||
.admin-nav-bar {
|
||||
padding: 0.5rem 2rem;
|
||||
}
|
||||
.admin-main-content {
|
||||
padding: 2.5rem 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Table Styles */
|
||||
.table-container {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 0.85rem 1rem;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
th {
|
||||
background-color: var(--surface-muted);
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
`;
|
||||
Loading…
x
Reference in New Issue
Block a user