misc
This commit is contained in:
@@ -89,7 +89,5 @@
|
|||||||
ready();
|
ready();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Support both original and obfuscated event names
|
|
||||||
document.addEventListener("astro:page-load", initMobileMenu);
|
document.addEventListener("astro:page-load", initMobileMenu);
|
||||||
document.addEventListener("c:ready", initMobileMenu);
|
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -91,5 +91,4 @@
|
|||||||
d.classList.add('ready');
|
d.classList.add('ready');
|
||||||
};
|
};
|
||||||
document.addEventListener('astro:after-swap', swapHandler);
|
document.addEventListener('astro:after-swap', swapHandler);
|
||||||
document.addEventListener('c:swap', swapHandler);
|
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -847,6 +847,12 @@ Custom
|
|||||||
/*
|
/*
|
||||||
Toastify customizations
|
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 {
|
.Toastify__toast {
|
||||||
border-radius: 12px !important;
|
border-radius: 12px !important;
|
||||||
backdrop-filter: blur(12px) !important;
|
backdrop-filter: blur(12px) !important;
|
||||||
|
|||||||
@@ -136,3 +136,25 @@ nav {
|
|||||||
.nav-user-inline--mobile {
|
.nav-user-inline--mobile {
|
||||||
margin: 0.75rem 0;
|
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]);
|
}, [currentTrackUuid]);
|
||||||
|
|
||||||
// Always define queueFooter, fallback to Close button if user is not available
|
// 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
|
const queueFooter = isDJ
|
||||||
? (
|
? (
|
||||||
<div className="flex gap-2 justify-end">
|
<div className="flex gap-2 justify-end">
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ export default function BreadcrumbNav({ currentPage }) {
|
|||||||
<React.Fragment key={key}>
|
<React.Fragment key={key}>
|
||||||
<a
|
<a
|
||||||
href={href}
|
href={href}
|
||||||
data-astro-reload
|
|
||||||
className={`px-3 py-1.5 rounded-full transition-colors ${isActive
|
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"
|
? "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"
|
: "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(() => {
|
useEffect(() => {
|
||||||
if (isDialogVisible && selectedRequest) {
|
if (isDialogVisible && selectedRequest) {
|
||||||
// Start polling
|
// Start polling
|
||||||
@@ -89,7 +90,7 @@ export default function RequestManagement() {
|
|||||||
}, [isDialogVisible, selectedRequest?.id]);
|
}, [isDialogVisible, selectedRequest?.id]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const hasActive = requests.some((j) => ["Queued", "Started", "Compressing"].includes(j.status));
|
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) {
|
else if (!hasActive && pollingRef.current) {
|
||||||
clearInterval(pollingRef.current);
|
clearInterval(pollingRef.current);
|
||||||
pollingRef.current = null;
|
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
|
// 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
|
// Use 'exclude' array to exempt specific sub-paths from protection
|
||||||
export const PROTECTED_ROUTES = [
|
export const PROTECTED_ROUTES = [
|
||||||
'/radio',
|
|
||||||
{ path: '/lighting', roles: ['lighting'] },
|
|
||||||
{ path: '/discord-logs', roles: ['discord'] },
|
{ path: '/discord-logs', roles: ['discord'] },
|
||||||
{ path: '/api/discord', roles: ['discord'], exclude: ['/api/discord/cached-image'] },
|
{ path: '/api/discord', roles: ['discord'], exclude: ['/api/discord/cached-image'] },
|
||||||
'/TRip',
|
{ path: '/TRip', roles: ['trip'] },
|
||||||
'/TRip/requests',
|
{ path: '/TRip/requests', roles: ['trip'] },
|
||||||
|
{ path: '/lighting', roles: ['lighting'] },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Routes that should skip auth check entirely (public routes)
|
// 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
|
// Nav is the standard site navigation — whitelabel logic belongs in SubNav
|
||||||
const isLoggedIn = Boolean(user);
|
const isLoggedIn = Boolean(user);
|
||||||
const userDisplayName = user?.user ?? null;
|
const userDisplayName = user?.user ?? null;
|
||||||
|
const isAdmin = user?.roles?.includes('admin') ?? false;
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ label: "Home", href: "/" },
|
{ label: "Home", href: "/" },
|
||||||
{ label: "Radio", href: "/radio" },
|
{ label: "Radio", href: "/radio" },
|
||||||
{ label: "Memes", href: "/memes" },
|
{ label: "Memes", href: "/memes" },
|
||||||
{ label: "TRip", href: "/TRip", auth: true, icon: "pirate" },
|
{ label: "TRip", href: "/TRip", auth: true, icon: "pirate" },
|
||||||
{ label: "Lighting", href: "/lighting", auth: true },
|
|
||||||
{ label: "Discord Logs", href: "/discord-logs", 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: "Status", href: "https://status.boatson.boats", icon: "external" },
|
||||||
{ label: "Git", href: "https://kode.boatson.boats", icon: "external" },
|
{ label: "Git", href: "https://kode.boatson.boats", icon: "external" },
|
||||||
{ label: "Login", href: "/login", guestOnly: true },
|
{ label: "Login", href: "/login", guestOnly: true },
|
||||||
@@ -62,7 +63,6 @@ const currentPath = Astro.url.pathname;
|
|||||||
{visibleNavItems.map((item) => {
|
{visibleNavItems.map((item) => {
|
||||||
const isExternal = item.href?.startsWith("http");
|
const isExternal = item.href?.startsWith("http");
|
||||||
const isAuthedPath = item.auth ?? false;
|
const isAuthedPath = item.auth ?? false;
|
||||||
const isTRipLink = item.href?.startsWith("/TRip");
|
|
||||||
const normalize = (url) => (url || '/').replace(/\/+$/, '') || '/';
|
const normalize = (url) => (url || '/').replace(/\/+$/, '') || '/';
|
||||||
const normalizedCurrent = normalize(currentPath);
|
const normalizedCurrent = normalize(currentPath);
|
||||||
const normalizedHref = normalize(item.href);
|
const normalizedHref = normalize(item.href);
|
||||||
@@ -83,7 +83,6 @@ const currentPath = Astro.url.pathname;
|
|||||||
target={isExternal ? "_blank" : undefined}
|
target={isExternal ? "_blank" : undefined}
|
||||||
rel={(isExternal || isAuthedPath) ? "external" : undefined}
|
rel={(isExternal || isAuthedPath) ? "external" : undefined}
|
||||||
onclick={item.onclick}
|
onclick={item.onclick}
|
||||||
data-astro-reload={isTRipLink ? true : undefined}
|
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
{item.icon === "external" && (
|
{item.icon === "external" && (
|
||||||
@@ -102,7 +101,7 @@ const currentPath = Astro.url.pathname;
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
{isLoggedIn && userDisplayName && (
|
{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__icon" aria-hidden="true" set:html={userIconSvg}></span>
|
||||||
<span class="nav-user-inline__name">{userDisplayName}</span>
|
<span class="nav-user-inline__name">{userDisplayName}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -159,7 +158,7 @@ const currentPath = Astro.url.pathname;
|
|||||||
class="mobile-menu-dropdown md:hidden"
|
class="mobile-menu-dropdown md:hidden"
|
||||||
>
|
>
|
||||||
{isLoggedIn && userDisplayName && (
|
{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__icon" aria-hidden="true" set:html={userIconSvg}></span>
|
||||||
<span class="nav-user-inline__name">{userDisplayName}</span>
|
<span class="nav-user-inline__name">{userDisplayName}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -168,7 +167,6 @@ const currentPath = Astro.url.pathname;
|
|||||||
{visibleNavItems.map((item) => {
|
{visibleNavItems.map((item) => {
|
||||||
const isExternal = item.href?.startsWith("http");
|
const isExternal = item.href?.startsWith("http");
|
||||||
const isAuthedPath = item.auth ?? false;
|
const isAuthedPath = item.auth ?? false;
|
||||||
const isTRipLink = item.href?.startsWith("/TRip");
|
|
||||||
const normalize = (url) => (url || '/').replace(/\/+$/, '') || '/';
|
const normalize = (url) => (url || '/').replace(/\/+$/, '') || '/';
|
||||||
const normalizedCurrent = normalize(currentPath);
|
const normalizedCurrent = normalize(currentPath);
|
||||||
const normalizedHref = normalize(item.href);
|
const normalizedHref = normalize(item.href);
|
||||||
@@ -190,7 +188,6 @@ const currentPath = Astro.url.pathname;
|
|||||||
target={isExternal ? "_blank" : undefined}
|
target={isExternal ? "_blank" : undefined}
|
||||||
rel={(isExternal || isAuthedPath) ? "external" : undefined}
|
rel={(isExternal || isAuthedPath) ? "external" : undefined}
|
||||||
onclick={item.onclick}
|
onclick={item.onclick}
|
||||||
data-astro-reload={isTRipLink ? true : undefined}
|
|
||||||
>
|
>
|
||||||
<span style="color:inherit;">{item.label}</span>
|
<span style="color:inherit;">{item.label}</span>
|
||||||
{item.icon === "external" && (
|
{item.icon === "external" && (
|
||||||
|
|||||||
@@ -156,70 +156,73 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|||||||
try {
|
try {
|
||||||
const pathname = context.url.pathname;
|
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 skipAuthPrefixes = ['/_astro', '/_', '/assets', '/scripts', '/favicon', '/images', '/_static'];
|
||||||
const shouldSkipAuth = skipAuthPrefixes.some(p => pathname.startsWith(p));
|
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 protectedConfig = getProtectedRouteConfig(pathname);
|
||||||
|
const isApiRoute = pathname.startsWith('/api/');
|
||||||
if (import.meta.env.DEV) console.log(`[middleware] Path: ${pathname}, Protected: ${!!protectedConfig}, SkipAuth: ${shouldSkipAuth}`);
|
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);
|
const { authenticated, user, cookies } = await checkAuth(context.request);
|
||||||
if (import.meta.env.DEV) console.log(`[middleware] Auth result: authenticated=${authenticated}`);
|
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
|
// Expose authenticated user and refreshed cookies to downstream handlers/layouts
|
||||||
context.locals.user = user;
|
if (authenticated && user) {
|
||||||
if (cookies && cookies.length > 0) {
|
context.locals.user = user;
|
||||||
context.locals.refreshedCookies = cookies;
|
if (cookies && cookies.length > 0) {
|
||||||
|
context.locals.refreshedCookies = cookies;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check role-based access if roles are specified
|
// For protected routes, enforce authentication
|
||||||
if (protectedConfig.roles && protectedConfig.roles.length > 0) {
|
if (protectedConfig && !isPublicRoute(pathname)) {
|
||||||
const userRoles = user?.roles || [];
|
if (!authenticated) {
|
||||||
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}`);
|
|
||||||
|
|
||||||
if (isApiRoute) {
|
if (isApiRoute) {
|
||||||
// Return JSON 403 for API routes
|
// Return JSON 401 for API routes
|
||||||
return new Response(JSON.stringify({
|
return new Response(JSON.stringify({ error: 'Unauthorized', message: 'Authentication required' }), {
|
||||||
error: 'Forbidden',
|
status: 401,
|
||||||
message: 'Insufficient permissions',
|
|
||||||
requiredRoles: protectedConfig.roles
|
|
||||||
}), {
|
|
||||||
status: 403,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// Redirect to login with return URL
|
||||||
// Store required roles in locals for the login page to access
|
const returnUrl = encodeURIComponent(pathname + context.url.search);
|
||||||
context.locals.accessDenied = true;
|
if (import.meta.env.DEV) console.log(`[middleware] Auth required for ${pathname}, redirecting to login`);
|
||||||
context.locals.requiredRoles = protectedConfig.roles;
|
return context.redirect(`/login?returnUrl=${returnUrl}`, 302);
|
||||||
context.locals.returnUrl = pathname + context.url.search;
|
|
||||||
// Rewrite to login page - this renders /login but keeps the URL and locals
|
|
||||||
return context.rewrite('/login');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// Check the Host header to differentiate subdomains
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import Base from "../layouts/Base.astro";
|
import Base from "../layouts/Base.astro";
|
||||||
import Root from "../components/AppLayout.jsx";
|
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;
|
const user = Astro.locals.user as any;
|
||||||
---
|
---
|
||||||
<Base>
|
<Base>
|
||||||
|
|||||||
Reference in New Issue
Block a user