misc
This commit is contained in:
@@ -89,7 +89,5 @@
|
||||
ready();
|
||||
}
|
||||
|
||||
// Support both original and obfuscated event names
|
||||
document.addEventListener("astro:page-load", initMobileMenu);
|
||||
document.addEventListener("c:ready", initMobileMenu);
|
||||
})();
|
||||
|
||||
@@ -91,5 +91,4 @@
|
||||
d.classList.add('ready');
|
||||
};
|
||||
document.addEventListener('astro:after-swap', swapHandler);
|
||||
document.addEventListener('c:swap', swapHandler);
|
||||
})();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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" && (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user