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.
|
* Checks if a user has a global admin role.
|
||||||
*/
|
*/
|
||||||
export async function isGlobalAdmin(userId: string): Promise<boolean> {
|
export async function isGlobalAdmin(userId: string): Promise<boolean> {
|
||||||
const result = await sqlWrapper.sql`
|
try {
|
||||||
SELECT r.name FROM user_roles ur
|
// Check 1: User has an explicit 'admin' grant
|
||||||
JOIN roles r ON ur.role_id = r.id
|
const adminGrant = await sqlWrapper.sql`
|
||||||
WHERE ur.user_id = ${userId} AND r.name = 'admin'
|
SELECT g.id
|
||||||
`;
|
FROM grants g
|
||||||
return result.length > 0;
|
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,
|
AdminUsersPageFragment,
|
||||||
AuditLogPageFragment,
|
AuditLogPageFragment,
|
||||||
} from "./fragments.tsx";
|
} 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 () => {
|
test("admin slice UI endpoints are protected by auth middleware", async () => {
|
||||||
const app = new Hono();
|
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);
|
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", () => {
|
test("admin fragment components render valid HTML markup", () => {
|
||||||
const usersFragment = AdminUsersPageFragment({
|
const usersFragment = AdminUsersPageFragment({
|
||||||
users: [
|
users: [
|
||||||
|
|||||||
@ -40,6 +40,8 @@ adminRoutes.use("*", adminRateLimiter);
|
|||||||
|
|
||||||
// --- HTML Pages & UI Routes ---
|
// --- HTML Pages & UI Routes ---
|
||||||
|
|
||||||
|
adminRoutes.get("/", (c) => c.redirect("/admin/users"));
|
||||||
|
|
||||||
adminRoutes.get("/users", async (c) => {
|
adminRoutes.get("/users", async (c) => {
|
||||||
const auth = await getAuthenticatedUser(c);
|
const auth = await getAuthenticatedUser(c);
|
||||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
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,
|
AuthenticatedLayoutFragment,
|
||||||
} from "../../shared/ui/layout_fragments.tsx";
|
} from "../../shared/ui/layout_fragments.tsx";
|
||||||
import { getAuthenticatedUser, hasScope } from "../../core/session.ts";
|
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 { getActiveSessions } from "./queries.ts";
|
||||||
import {
|
import {
|
||||||
@ -15,6 +15,7 @@ import {
|
|||||||
SessionDeckFragment,
|
SessionDeckFragment,
|
||||||
SessionTableFragment,
|
SessionTableFragment,
|
||||||
} from "./fragments.tsx";
|
} from "./fragments.tsx";
|
||||||
|
import { PasskeysPageFragment } from "./passkeys_fragments.tsx";
|
||||||
import { sessionActionsRoutes } from "./actions_routes.ts";
|
import { sessionActionsRoutes } from "./actions_routes.ts";
|
||||||
|
|
||||||
export const sessionRoutes = new Hono();
|
export const sessionRoutes = new Hono();
|
||||||
@ -22,6 +23,23 @@ export const sessionRoutes = new Hono();
|
|||||||
// Mount Actions sub-router
|
// Mount Actions sub-router
|
||||||
sessionRoutes.route("/", sessionActionsRoutes);
|
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
|
// 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:audit");
|
||||||
assertStringIncludes(String(html), "read:users");
|
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 { COMMON_CSS } from "./CommonStyles.ts";
|
||||||
|
import { LAYOUT_CSS } from "./layout_styles.ts";
|
||||||
import { NavbarFragment } from "./navbar_fragments.tsx";
|
import { NavbarFragment } from "./navbar_fragments.tsx";
|
||||||
|
|
||||||
export const LayoutFragment = ({
|
export const LayoutFragment = ({
|
||||||
@ -21,229 +22,7 @@ export const LayoutFragment = ({
|
|||||||
<style>
|
<style>
|
||||||
{`
|
{`
|
||||||
${COMMON_CSS}
|
${COMMON_CSS}
|
||||||
|
${LAYOUT_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;
|
|
||||||
}
|
|
||||||
`}
|
`}
|
||||||
</style>
|
</style>
|
||||||
{/* Datastar script */}
|
{/* 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