Phase 2: Docker and OIDC auth
This commit is contained in:
+220
-46
@@ -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 />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user