343 lines
8.0 KiB
TypeScript
343 lines
8.0 KiB
TypeScript
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,
|
|
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 { Applications } from "./pages/Applications";
|
|
import { Settings } from "./pages/Settings";
|
|
import { UsersPage } from "./pages/Users";
|
|
import { FileBrowser } from "./pages/FileBrowser";
|
|
import { Actions } from "./pages/Actions";
|
|
import { getAppTheme } from "./theme";
|
|
import { getOidcConfig, isOidcConfigured, setAccessToken } from "./auth";
|
|
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false } },
|
|
});
|
|
|
|
function Shell({
|
|
darkMode,
|
|
authLabel,
|
|
onSignOut,
|
|
}: {
|
|
darkMode: boolean;
|
|
authLabel?: string;
|
|
onSignOut?: () => void;
|
|
}) {
|
|
const location = useLocation();
|
|
const current = location.pathname;
|
|
const isMobile = useMediaQuery("(max-width: 900px)");
|
|
|
|
return (
|
|
<>
|
|
<CssBaseline />
|
|
<AppBar
|
|
position="sticky"
|
|
color="inherit"
|
|
elevation={0}
|
|
sx={{
|
|
borderBottom: 1,
|
|
borderColor: "divider",
|
|
backgroundImage: "none",
|
|
}}
|
|
>
|
|
<Box
|
|
sx={{
|
|
display: "flex",
|
|
flexDirection: { xs: "column", md: "row" },
|
|
alignItems: { xs: "stretch", md: "center" },
|
|
justifyContent: "space-between",
|
|
gap: 1.5,
|
|
px: { xs: 1.5, sm: 2.5, md: 3 },
|
|
py: { xs: 1, md: 1.25 },
|
|
}}
|
|
>
|
|
<Box
|
|
sx={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 1.5,
|
|
minWidth: 0,
|
|
}}
|
|
>
|
|
<Box
|
|
sx={{
|
|
width: 42,
|
|
height: 42,
|
|
borderRadius: 2,
|
|
display: "grid",
|
|
placeItems: "center",
|
|
fontWeight: 800,
|
|
fontSize: 20,
|
|
color: "primary.contrastText",
|
|
bgcolor: "primary.main",
|
|
backgroundImage:
|
|
"linear-gradient(135deg, rgba(255,255,255,0.22), rgba(255,255,255,0))",
|
|
boxShadow: 1,
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
M
|
|
</Box>
|
|
<Box sx={{ minWidth: 0 }}>
|
|
<Typography
|
|
variant="h6"
|
|
sx={{ fontWeight: 800, lineHeight: 1.05 }}
|
|
>
|
|
Manage
|
|
</Typography>
|
|
<Typography variant="caption" color="text.secondary" noWrap>
|
|
Media operations dashboard
|
|
</Typography>
|
|
</Box>
|
|
</Box>
|
|
|
|
<Stack
|
|
direction="row"
|
|
spacing={1}
|
|
sx={{
|
|
alignItems: "center",
|
|
flexWrap: "wrap",
|
|
justifyContent: { xs: "flex-start", md: "flex-end" },
|
|
width: { xs: "100%", md: "auto" },
|
|
}}
|
|
>
|
|
{authLabel && (
|
|
<Chip
|
|
label={authLabel}
|
|
variant="outlined"
|
|
sx={{ maxWidth: { xs: "100%", sm: 220 } }}
|
|
/>
|
|
)}
|
|
<Chip label={darkMode ? "Dark" : "Light"} variant="outlined" />
|
|
{onSignOut && (
|
|
<Button
|
|
variant="outlined"
|
|
onClick={onSignOut}
|
|
sx={{ whiteSpace: "nowrap" }}
|
|
>
|
|
Logout
|
|
</Button>
|
|
)}
|
|
</Stack>
|
|
</Box>
|
|
|
|
<Box sx={{ borderTop: 1, borderColor: "divider" }}>
|
|
<Tabs
|
|
value={current}
|
|
textColor="primary"
|
|
indicatorColor="primary"
|
|
variant={isMobile ? "scrollable" : "standard"}
|
|
scrollButtons="auto"
|
|
allowScrollButtonsMobile
|
|
sx={{
|
|
px: { xs: 0.75, sm: 1.5, md: 3 },
|
|
minHeight: 44,
|
|
"& .MuiTab-root": {
|
|
minHeight: 44,
|
|
py: 1,
|
|
px: 1.5,
|
|
minWidth: { xs: 88, md: 108 },
|
|
textTransform: "none",
|
|
fontWeight: 600,
|
|
},
|
|
}}
|
|
>
|
|
<Tab value="/" label="Dashboard" component={NavLink} to="/" />
|
|
<Tab
|
|
value="/monitoring"
|
|
label="Monitoring"
|
|
component={NavLink}
|
|
to="/monitoring"
|
|
/>
|
|
<Tab
|
|
value="/applications"
|
|
label="Applications"
|
|
component={NavLink}
|
|
to="/applications"
|
|
/>
|
|
<Tab value="/users" label="Users" component={NavLink} to="/users" />
|
|
<Tab
|
|
value="/actions"
|
|
label="Actions"
|
|
component={NavLink}
|
|
to="/actions"
|
|
/>
|
|
<Tab value="/files" label="Files" component={NavLink} to="/files" />
|
|
<Tab
|
|
value="/settings"
|
|
label="Settings"
|
|
component={NavLink}
|
|
to="/settings"
|
|
/>
|
|
</Tabs>
|
|
</Box>
|
|
</AppBar>
|
|
<Container
|
|
maxWidth={false}
|
|
sx={{ py: { xs: 1.5, md: 2 }, px: { xs: 1.25, sm: 2, md: 2.5 } }}
|
|
>
|
|
<Routes>
|
|
<Route path="/" element={<Dashboard />} />
|
|
<Route path="/monitoring" element={<Monitoring />} />
|
|
<Route path="/applications" element={<Applications />} />
|
|
<Route path="/media" element={<Applications />} />
|
|
<Route path="/users" element={<UsersPage />} />
|
|
<Route path="/actions" element={<Actions />} />
|
|
<Route path="/files" element={<FileBrowser />} />
|
|
<Route path="/settings" element={<Settings />} />
|
|
</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 Manage.
|
|
</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 <AppInner />;
|
|
}
|