This commit is contained in:
2025-12-18 11:19:01 -05:00
parent 3e3d9ed89b
commit 2327e330de
11 changed files with 93 additions and 65 deletions

View File

@@ -89,7 +89,5 @@
ready();
}
// Support both original and obfuscated event names
document.addEventListener("astro:page-load", initMobileMenu);
document.addEventListener("c:ready", initMobileMenu);
})();

View File

@@ -91,5 +91,4 @@
d.classList.add('ready');
};
document.addEventListener('astro:after-swap', swapHandler);
document.addEventListener('c:swap', swapHandler);
})();

View File

@@ -847,6 +847,12 @@ Custom
/*
Toastify customizations
*/
.Toastify__toast-container--top-right,
.Toastify__toast-container--top-center,
.Toastify__toast-container--top-left {
top: 80px !important; /* keep below sticky nav */
}
.Toastify__toast {
border-radius: 12px !important;
backdrop-filter: blur(12px) !important;

View File

@@ -136,3 +136,25 @@ nav {
.nav-user-inline--mobile {
margin: 0.75rem 0;
}
.nav-user-inline--admin {
border-color: rgba(239, 68, 68, 0.4);
background: linear-gradient(135deg, rgba(254, 242, 242, 0.95), rgba(254, 226, 226, 0.9));
box-shadow: 0 1px 3px rgba(239, 68, 68, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.6);
}
.nav-user-inline--admin:hover {
border-color: rgba(239, 68, 68, 0.6);
box-shadow: 0 2px 6px rgba(239, 68, 68, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.6);
}
[data-theme="dark"] .nav-user-inline--admin {
border-color: rgba(239, 68, 68, 0.5);
background: linear-gradient(135deg, rgba(127, 29, 29, 0.6), rgba(69, 10, 10, 0.5));
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(239, 68, 68, 0.1);
}
[data-theme="dark"] .nav-user-inline--admin:hover {
border-color: rgba(248, 113, 113, 0.6);
box-shadow: 0 2px 8px rgba(239, 68, 68, 0.3), inset 0 1px 0 rgba(239, 68, 68, 0.1);
}

View File

@@ -652,7 +652,11 @@ export default function Player({ user }) {
}, [currentTrackUuid]);
// Always define queueFooter, fallback to Close button if user is not available
const isDJ = (user && user.roles.includes('dj')) || ENVIRONMENT === "Dev";
// Normalize roles to lowercase for case-insensitive comparison
const userRoles = Array.isArray(user?.roles)
? user.roles.map((r) => (typeof r === 'string' ? r.toLowerCase() : r)).filter(Boolean)
: [];
const isDJ = userRoles.includes('dj') || userRoles.includes('admin') || ENVIRONMENT === "Dev";
const queueFooter = isDJ
? (
<div className="flex gap-2 justify-end">

View File

@@ -14,7 +14,6 @@ export default function BreadcrumbNav({ currentPage }) {
<React.Fragment key={key}>
<a
href={href}
data-astro-reload
className={`px-3 py-1.5 rounded-full transition-colors ${isActive
? "bg-neutral-200 dark:bg-neutral-700 font-semibold text-neutral-900 dark:text-white"
: "text-neutral-500 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800"

View File

@@ -68,7 +68,8 @@ export default function RequestManagement() {
}
};
useEffect(() => { fetchJobs(); }, []);
// Initial load shows the skeleton; subsequent polling should not
useEffect(() => { fetchJobs(true); }, []);
useEffect(() => {
if (isDialogVisible && selectedRequest) {
// Start polling
@@ -89,7 +90,7 @@ export default function RequestManagement() {
}, [isDialogVisible, selectedRequest?.id]);
useEffect(() => {
const hasActive = requests.some((j) => ["Queued", "Started", "Compressing"].includes(j.status));
if (hasActive && !pollingRef.current) pollingRef.current = setInterval(fetchJobs, 1500);
if (hasActive && !pollingRef.current) pollingRef.current = setInterval(() => fetchJobs(false), 1500);
else if (!hasActive && pollingRef.current) {
clearInterval(pollingRef.current);
pollingRef.current = null;

View File

@@ -58,12 +58,11 @@ export const SUBSITES = {
// Can be a string (just auth required) or object with roles array for role-based access
// Use 'exclude' array to exempt specific sub-paths from protection
export const PROTECTED_ROUTES = [
'/radio',
{ path: '/lighting', roles: ['lighting'] },
{ path: '/discord-logs', roles: ['discord'] },
{ path: '/api/discord', roles: ['discord'], exclude: ['/api/discord/cached-image'] },
'/TRip',
'/TRip/requests',
{ path: '/TRip', roles: ['trip'] },
{ path: '/TRip/requests', roles: ['trip'] },
{ path: '/lighting', roles: ['lighting'] },
];
// Routes that should skip auth check entirely (public routes)

View File

@@ -14,14 +14,15 @@ const isReq = getSubsiteByHost(host)?.short === 'req' || getSubsiteByPath(Astro.
// Nav is the standard site navigation — whitelabel logic belongs in SubNav
const isLoggedIn = Boolean(user);
const userDisplayName = user?.user ?? null;
const isAdmin = user?.roles?.includes('admin') ?? false;
const navItems = [
{ label: "Home", href: "/" },
{ label: "Radio", href: "/radio" },
{ label: "Memes", href: "/memes" },
{ label: "TRip", href: "/TRip", auth: true, icon: "pirate" },
{ label: "Lighting", href: "/lighting", auth: true },
{ label: "Discord Logs", href: "/discord-logs", auth: true },
{ label: "Lighting", href: "/lighting", auth: true },
{ label: "Status", href: "https://status.boatson.boats", icon: "external" },
{ label: "Git", href: "https://kode.boatson.boats", icon: "external" },
{ label: "Login", href: "/login", guestOnly: true },
@@ -62,7 +63,6 @@ const currentPath = Astro.url.pathname;
{visibleNavItems.map((item) => {
const isExternal = item.href?.startsWith("http");
const isAuthedPath = item.auth ?? false;
const isTRipLink = item.href?.startsWith("/TRip");
const normalize = (url) => (url || '/').replace(/\/+$/, '') || '/';
const normalizedCurrent = normalize(currentPath);
const normalizedHref = normalize(item.href);
@@ -83,7 +83,6 @@ const currentPath = Astro.url.pathname;
target={isExternal ? "_blank" : undefined}
rel={(isExternal || isAuthedPath) ? "external" : undefined}
onclick={item.onclick}
data-astro-reload={isTRipLink ? true : undefined}
>
{item.label}
{item.icon === "external" && (
@@ -102,7 +101,7 @@ const currentPath = Astro.url.pathname;
</ul>
{isLoggedIn && userDisplayName && (
<div class="nav-user-inline" title={`Logged in as ${userDisplayName}`}>
<div class:list={['nav-user-inline', isAdmin && 'nav-user-inline--admin']} title={`Logged in as ${userDisplayName}`}>
<span class="nav-user-inline__icon" aria-hidden="true" set:html={userIconSvg}></span>
<span class="nav-user-inline__name">{userDisplayName}</span>
</div>
@@ -159,7 +158,7 @@ const currentPath = Astro.url.pathname;
class="mobile-menu-dropdown md:hidden"
>
{isLoggedIn && userDisplayName && (
<div class="nav-user-inline nav-user-inline--mobile" title={`Logged in as ${userDisplayName}`}>
<div class:list={['nav-user-inline', 'nav-user-inline--mobile', isAdmin && 'nav-user-inline--admin']} title={`Logged in as ${userDisplayName}`}>
<span class="nav-user-inline__icon" aria-hidden="true" set:html={userIconSvg}></span>
<span class="nav-user-inline__name">{userDisplayName}</span>
</div>
@@ -168,7 +167,6 @@ const currentPath = Astro.url.pathname;
{visibleNavItems.map((item) => {
const isExternal = item.href?.startsWith("http");
const isAuthedPath = item.auth ?? false;
const isTRipLink = item.href?.startsWith("/TRip");
const normalize = (url) => (url || '/').replace(/\/+$/, '') || '/';
const normalizedCurrent = normalize(currentPath);
const normalizedHref = normalize(item.href);
@@ -190,7 +188,6 @@ const currentPath = Astro.url.pathname;
target={isExternal ? "_blank" : undefined}
rel={(isExternal || isAuthedPath) ? "external" : undefined}
onclick={item.onclick}
data-astro-reload={isTRipLink ? true : undefined}
>
<span style="color:inherit;">{item.label}</span>
{item.icon === "external" && (

View File

@@ -156,70 +156,73 @@ export const onRequest = defineMiddleware(async (context, next) => {
try {
const pathname = context.url.pathname;
// Skip auth check for static assets and API routes
// Skip auth check for static assets
const skipAuthPrefixes = ['/_astro', '/_', '/assets', '/scripts', '/favicon', '/images', '/_static'];
const shouldSkipAuth = skipAuthPrefixes.some(p => pathname.startsWith(p));
// Check authentication for protected routes
// Check if route is protected (requires auth)
const protectedConfig = getProtectedRouteConfig(pathname);
const isApiRoute = pathname.startsWith('/api/');
if (import.meta.env.DEV) console.log(`[middleware] Path: ${pathname}, Protected: ${!!protectedConfig}, SkipAuth: ${shouldSkipAuth}`);
if (!shouldSkipAuth && protectedConfig && !isPublicRoute(pathname)) {
// Always attempt auth for non-static routes to populate user info
if (!shouldSkipAuth) {
const { authenticated, user, cookies } = await checkAuth(context.request);
if (import.meta.env.DEV) console.log(`[middleware] Auth result: authenticated=${authenticated}`);
const isApiRoute = pathname.startsWith('/api/');
if (!authenticated) {
if (isApiRoute) {
// Return JSON 401 for API routes
return new Response(JSON.stringify({ error: 'Unauthorized', message: 'Authentication required' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
});
}
// Redirect to login with return URL
const returnUrl = encodeURIComponent(pathname + context.url.search);
if (import.meta.env.DEV) console.log(`[middleware] Auth required for ${pathname}, redirecting to login`);
return context.redirect(`/login?returnUrl=${returnUrl}`, 302);
}
// Expose authenticated user and refreshed cookies to downstream handlers/layouts
context.locals.user = user;
if (cookies && cookies.length > 0) {
context.locals.refreshedCookies = cookies;
if (authenticated && user) {
context.locals.user = user;
if (cookies && cookies.length > 0) {
context.locals.refreshedCookies = cookies;
}
}
// Check role-based access if roles are specified
if (protectedConfig.roles && protectedConfig.roles.length > 0) {
const userRoles = user?.roles || [];
const hasRequiredRole = protectedConfig.roles.some(role => userRoles.includes(role));
if (!hasRequiredRole) {
if (import.meta.env.DEV) console.log(`[middleware] User lacks required role for ${pathname}`);
// For protected routes, enforce authentication
if (protectedConfig && !isPublicRoute(pathname)) {
if (!authenticated) {
if (isApiRoute) {
// Return JSON 403 for API routes
return new Response(JSON.stringify({
error: 'Forbidden',
message: 'Insufficient permissions',
requiredRoles: protectedConfig.roles
}), {
status: 403,
// Return JSON 401 for API routes
return new Response(JSON.stringify({ error: 'Unauthorized', message: 'Authentication required' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
});
}
// Store required roles in locals for the login page to access
context.locals.accessDenied = true;
context.locals.requiredRoles = protectedConfig.roles;
context.locals.returnUrl = pathname + context.url.search;
// Rewrite to login page - this renders /login but keeps the URL and locals
return context.rewrite('/login');
// Redirect to login with return URL
const returnUrl = encodeURIComponent(pathname + context.url.search);
if (import.meta.env.DEV) console.log(`[middleware] Auth required for ${pathname}, redirecting to login`);
return context.redirect(`/login?returnUrl=${returnUrl}`, 302);
}
// Check role-based access if roles are specified
if (protectedConfig.roles && protectedConfig.roles.length > 0) {
const userRoles = user?.roles || [];
const isAdmin = userRoles.includes('admin');
const hasRequiredRole = isAdmin || protectedConfig.roles.some(role => userRoles.includes(role));
if (!hasRequiredRole) {
if (import.meta.env.DEV) console.log(`[middleware] User lacks required role for ${pathname}`);
if (isApiRoute) {
// Return JSON 403 for API routes
return new Response(JSON.stringify({
error: 'Forbidden',
message: 'Insufficient permissions',
requiredRoles: protectedConfig.roles
}), {
status: 403,
headers: { 'Content-Type': 'application/json' },
});
}
// Store required roles in locals for the login page to access
context.locals.accessDenied = true;
context.locals.requiredRoles = protectedConfig.roles;
context.locals.returnUrl = pathname + context.url.search;
// Rewrite to login page - this renders /login but keeps the URL and locals
return context.rewrite('/login');
}
}
if (import.meta.env.DEV) console.log(`[middleware] Auth OK for ${pathname}, user: ${user?.username || user?.id || 'unknown'}`);
}
if (import.meta.env.DEV) console.log(`[middleware] Auth OK for ${pathname}, user: ${user?.username || user?.id || 'unknown'}`);
}
// Check the Host header to differentiate subdomains

View File

@@ -2,7 +2,7 @@
import Base from "../layouts/Base.astro";
import Root from "../components/AppLayout.jsx";
// Auth handled by middleware - user available in Astro.locals.user
// User populated by middleware when logged in
const user = Astro.locals.user as any;
---
<Base>