Phase 2: Docker and OIDC auth

This commit is contained in:
2026-05-04 13:50:53 +02:00
parent 47baee854b
commit 4226628d5a
71 changed files with 9722 additions and 1347 deletions
+220 -46
View File
@@ -1,62 +1,236 @@
import { BrowserRouter, Routes, Route, NavLink } from "react-router-dom";
import {
BrowserRouter,
Routes,
Route,
NavLink,
useLocation,
} from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ThemeProvider } from "@mui/material/styles";
import {
AppBar,
Toolbar,
Typography,
Box,
Tabs,
Tab,
Container,
CssBaseline,
Chip,
useMediaQuery,
Button,
Stack,
Card,
CardContent,
CircularProgress,
} from "@mui/material";
import { useEffect, useMemo } from "react";
import { AuthProvider, useAuth } from "react-oidc-context";
import { Dashboard } from "./pages/Dashboard";
import { Monitoring } from "./pages/Monitoring";
import { Media } from "./pages/Media";
import { UsersPage } from "./pages/Users";
import { FileBrowser } from "./pages/FileBrowser";
import { getAppTheme } from "./theme";
import { getOidcConfig, isOidcConfigured, setAccessToken } from "./auth";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: false,
},
},
defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false } },
});
const navLinks = [
{ to: "/", label: "Dashboard" },
{ to: "/monitoring", label: "Monitoring" },
{ to: "/media", label: "Media" },
{ to: "/files", label: "File Browser" },
];
function Shell({
darkMode,
authLabel,
onSignOut,
}: {
darkMode: boolean;
authLabel?: string;
onSignOut?: () => void;
}) {
const location = useLocation();
const current = location.pathname;
function NavBar() {
return (
<nav className="border-b px-6 py-3 flex gap-6 items-center bg-white sticky top-0 z-10">
<span className="font-bold text-lg mr-4">Media Library Viewer</span>
{navLinks.map((link) => (
<NavLink
key={link.to}
to={link.to}
end={link.to === "/"}
className={({ isActive }) =>
`text-sm px-2 py-1 rounded ${isActive ? "bg-gray-100 font-medium" : "text-gray-600 hover:text-gray-900"}`
}
>
{link.label}
</NavLink>
))}
</nav>
<>
<CssBaseline />
<AppBar position="sticky" color="inherit" elevation={0}>
<Toolbar sx={{ display: "flex", gap: 2, minHeight: 68 }}>
<Typography variant="h6" sx={{ mr: 2, fontWeight: 700 }}>
Media Library Viewer
</Typography>
<Tabs
value={current}
textColor="primary"
indicatorColor="primary"
sx={{ flex: 1 }}
>
<Tab value="/" label="Dashboard" component={NavLink} to="/" />
<Tab
value="/monitoring"
label="Monitoring"
component={NavLink}
to="/monitoring"
/>
<Tab value="/media" label="Media" component={NavLink} to="/media" />
<Tab value="/users" label="Users" component={NavLink} to="/users" />
<Tab
value="/files"
label="File Browser"
component={NavLink}
to="/files"
/>
</Tabs>
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
{authLabel && (
<Chip size="small" variant="outlined" label={authLabel} />
)}
<Chip
size="small"
variant="outlined"
label={darkMode ? "Dark" : "Light"}
/>
{onSignOut && (
<Button size="small" variant="text" onClick={onSignOut}>
Sign out
</Button>
)}
</Stack>
</Toolbar>
</AppBar>
<Container maxWidth={false} sx={{ py: 3 }}>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/monitoring" element={<Monitoring />} />
<Route path="/media" element={<Media />} />
<Route path="/users" element={<UsersPage />} />
<Route path="/files" element={<FileBrowser />} />
</Routes>
</Container>
</>
);
}
function LoadingScreen({ label }: { label: string }) {
return (
<Box
sx={{
minHeight: "100vh",
display: "grid",
placeItems: "center",
p: 2,
}}
>
<Card variant="outlined" sx={{ maxWidth: 420, width: "100%" }}>
<CardContent>
<Stack spacing={2} sx={{ alignItems: "center", textAlign: "center" }}>
<CircularProgress />
<Typography variant="h6">{label}</Typography>
</Stack>
</CardContent>
</Card>
</Box>
);
}
function SignInScreen({ onSignIn }: { onSignIn: () => void }) {
return (
<Box
sx={{
minHeight: "100vh",
display: "grid",
placeItems: "center",
p: 2,
}}
>
<Card variant="outlined" sx={{ maxWidth: 460, width: "100%" }}>
<CardContent>
<Stack spacing={2} sx={{ alignItems: "center", textAlign: "center" }}>
<Typography variant="h5">Sign in required</Typography>
<Typography variant="body2" color="text.secondary">
Use your Authentik account to access the media library viewer.
</Typography>
<Button variant="contained" onClick={onSignIn}>
Sign in with OIDC
</Button>
</Stack>
</CardContent>
</Card>
</Box>
);
}
function AuthenticatedApp({ darkMode }: { darkMode: boolean }) {
const auth = useAuth();
useEffect(() => {
setAccessToken(auth.user?.access_token ?? null);
}, [auth.user?.access_token]);
const authLabel = useMemo(() => {
const profile = auth.user?.profile as Record<string, unknown> | undefined;
return String(
profile?.name ??
profile?.preferred_username ??
profile?.email ??
auth.user?.profile?.sub ??
"Authenticated",
);
}, [auth.user]);
if (auth.isLoading || auth.activeNavigator) {
return <LoadingScreen label="Checking sign-in…" />;
}
if (auth.error) {
return (
<LoadingScreen
label={`Authentication error: ${auth.error.message || "Unable to sign in"}`}
/>
);
}
if (!auth.isAuthenticated) {
return <SignInScreen onSignIn={() => void auth.signinRedirect()} />;
}
return (
<Box sx={{ minHeight: "100vh", bgcolor: "background.default" }}>
<BrowserRouter>
<Shell
darkMode={darkMode}
authLabel={authLabel}
onSignOut={() => void auth.signoutRedirect()}
/>
</BrowserRouter>
</Box>
);
}
function AppInner() {
const prefersDarkMode = useMediaQuery("(prefers-color-scheme: dark)");
const theme = useMemo(
() => getAppTheme(prefersDarkMode ? "dark" : "light"),
[prefersDarkMode],
);
return (
<ThemeProvider theme={theme}>
<QueryClientProvider client={queryClient}>
{isOidcConfigured() ? (
<AuthProvider {...getOidcConfig()}>
<AuthenticatedApp darkMode={prefersDarkMode} />
</AuthProvider>
) : (
<Box sx={{ minHeight: "100vh", bgcolor: "background.default" }}>
<BrowserRouter>
<Shell darkMode={prefersDarkMode} />
</BrowserRouter>
</Box>
)}
</QueryClientProvider>
</ThemeProvider>
);
}
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<div className="min-h-screen bg-gray-50">
<NavBar />
<main className="max-w-screen-2xl mx-auto px-6 py-6">
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/monitoring" element={<Monitoring />} />
<Route path="/media" element={<Media />} />
<Route path="/files" element={<FileBrowser />} />
</Routes>
</main>
</div>
</BrowserRouter>
</QueryClientProvider>
);
return <AppInner />;
}