OAuth & Auth Guard

This commit is contained in:
LittleSheep 2024-02-29 22:14:22 +08:00
parent e4ace4324a
commit 0a9369aba5
8 changed files with 328 additions and 78 deletions

Binary file not shown.

View File

@ -16,6 +16,7 @@ import AppShell from "@/components/AppShell.tsx";
import LandingPage from "@/pages/landing.tsx";
import SignUpPage from "@/pages/auth/sign-up.tsx";
import SignInPage from "@/pages/auth/sign-in.tsx";
import OauthConnectPage from "@/pages/auth/connect.tsx";
import DashboardPage from "@/pages/users/dashboard.tsx";
import ErrorBoundary from "@/error.tsx";
import AppLoader from "@/components/AppLoader.tsx";
@ -25,6 +26,8 @@ import PersonalizePage from "@/pages/users/personalize.tsx";
import SecurityPage from "@/pages/users/security.tsx";
import { UserinfoProvider } from "@/stores/userinfo.tsx";
import { WellKnownProvider } from "@/stores/wellKnown.tsx";
import AuthLayout from "@/pages/auth/layout.tsx";
import AuthGuard from "@/pages/guard.tsx";
declare const __GARFISH_EXPORTS__: {
provider: Object;
@ -45,19 +48,33 @@ const router = createBrowserRouter([
children: [
{ path: "/", element: <LandingPage /> },
{
path: "/users",
element: <UserLayout />,
path: "/",
element: <AuthGuard />,
children: [
{ path: "/users", element: <DashboardPage /> },
{ path: "/users/notifications", element: <NotificationsPage /> },
{ path: "/users/personalize", element: <PersonalizePage /> },
{ path: "/users/security", element: <SecurityPage /> }
{
path: "/users",
element: <UserLayout />,
children: [
{ path: "/users", element: <DashboardPage /> },
{ path: "/users/notifications", element: <NotificationsPage /> },
{ path: "/users/personalize", element: <PersonalizePage /> },
{ path: "/users/security", element: <SecurityPage /> }
]
}
]
}
]
},
{ path: "/auth/sign-up", element: <SignUpPage />, errorElement: <ErrorBoundary /> },
{ path: "/auth/sign-in", element: <SignInPage />, errorElement: <ErrorBoundary /> }
{
path: "/auth",
element: <AuthLayout />,
errorElement: <ErrorBoundary />,
children: [
{ path: "/auth/sign-up", element: <SignUpPage />, errorElement: <ErrorBoundary /> },
{ path: "/auth/sign-in", element: <SignInPage />, errorElement: <ErrorBoundary /> },
{ path: "/auth/o/connect", element: <OauthConnectPage />, errorElement: <ErrorBoundary /> }
]
}
]);
const element = (

View File

@ -0,0 +1,182 @@
import { useEffect, useState } from "react";
import {
Alert,
Avatar,
Box,
Button,
Card,
CardContent,
Collapse,
Grid,
LinearProgress,
Typography
} from "@mui/material";
import { request } from "@/scripts/request.ts";
import { useUserinfo } from "@/stores/userinfo.tsx";
import { useSearchParams } from "react-router-dom";
import OutletIcon from "@mui/icons-material/Outlet";
import WhatshotIcon from "@mui/icons-material/Whatshot";
export default function OauthConnectPage() {
const { getAtk } = useUserinfo();
const [panel, setPanel] = useState(0);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [client, setClient] = useState<any>(null);
const [searchParams] = useSearchParams();
async function preconnect() {
const res = await request(`/api/auth/o/connect${location.search}`, {
headers: { "Authorization": `Bearer ${getAtk()}` }
});
if (res.status !== 200) {
setError(await res.text());
} else {
const data = await res.json();
if (data["session"]) {
setPanel(1);
redirect(data["session"]);
} else {
setClient(data["client"]);
setLoading(false);
}
}
}
useEffect(() => {
preconnect().then(() => console.log("Fetched metadata"));
}, []);
function decline() {
if (window.history.length > 0) {
window.history.back();
} else {
window.close();
}
}
async function approve() {
setLoading(true);
const res = await request("/api/auth/o/connect?" + new URLSearchParams({
client_id: searchParams.get("client_id") as string,
redirect_uri: encodeURIComponent(searchParams.get("redirect_uri") as string),
response_type: "code",
scope: searchParams.get("scope") as string
}), {
method: "POST",
headers: { "Authorization": `Bearer ${getAtk()}` }
});
if (res.status !== 200) {
setError(await res.text());
setLoading(false);
} else {
const data = await res.json();
setPanel(1);
setTimeout(() => redirect(data["session"]), 1850);
}
}
function redirect(session: any) {
const url = `${searchParams.get("redirect_uri")}?code=${session["grant_token"]}&state=${searchParams.get("state")}`;
window.open(url, "_self");
}
const elements = [
(
<>
<Avatar sx={{ m: 1, bgcolor: "secondary.main" }}>
<OutletIcon />
</Avatar>
<Typography component="h1" variant="h5">
Sign in to {client?.name}
</Typography>
<Box sx={{ mt: 3, width: "100%" }}>
<Grid container spacing={2}>
<Grid item xs={12}>
<Typography fontWeight="bold">About this app</Typography>
<Typography variant="body2">{client?.description}</Typography>
</Grid>
<Grid item xs={12}>
<Typography fontWeight="bold">Make you trust this app</Typography>
<Typography variant="body2">
After you click Approve button, you will share your basic personal information to this application
developer. Some of them will leak your data. Think twice.
</Typography>
</Grid>
<Grid item xs={12} md={6}>
<Button
fullWidth
color="info"
variant="outlined"
disabled={loading}
sx={{ mt: 3 }}
onClick={() => decline()}
>
Decline
</Button>
</Grid>
<Grid item xs={12} md={6}>
<Button
fullWidth
variant="outlined"
disabled={loading}
sx={{ mt: 3 }}
onClick={() => approve()}
>
Approve
</Button>
</Grid>
</Grid>
</Box>
</>
),
(
<>
<Avatar sx={{ m: 1, bgcolor: "secondary.main" }}>
<WhatshotIcon />
</Avatar>
<Typography component="h1" variant="h5">
Authorized
</Typography>
<Box sx={{ mt: 3, width: "100%", textAlign: "center" }}>
<Grid container spacing={2}>
<Grid item xs={12} sx={{ my: 8 }}>
<Typography variant="h6">Now Redirecting...</Typography>
<Typography>Hold on a second, we are going to redirect you to the target.</Typography>
</Grid>
</Grid>
</Box>
</>
)
];
return (
<>
{error && <Alert severity="error" className="capitalize" sx={{ mb: 2 }}>{error}</Alert>}
<Card variant="outlined">
<Collapse in={loading}>
<LinearProgress />
</Collapse>
<CardContent
style={{ padding: "40px 48px 36px" }}
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center"
}}
>
{elements[panel]}
</CardContent>
</Card>
</>
);
}

View File

@ -0,0 +1,12 @@
import { Box } from "@mui/material";
import { Outlet } from "react-router-dom";
export default function AuthLayout() {
return (
<Box sx={{ height: "100vh", display: "flex", alignItems: "center", justifyContent: "center" }}>
<Box style={{ width: "100vw", maxWidth: "450px" }}>
<Outlet />
</Box>
</Box>
)
}

View File

@ -277,51 +277,55 @@ export default function SignInPage() {
}
return (
<Box sx={{ height: "100vh", display: "flex", alignItems: "center", justifyContent: "center" }}>
<Box style={{ width: "100vw", maxWidth: "450px" }}>
{error && <Alert severity="error" className="capitalize" sx={{ mb: 2 }}>{error}</Alert>}
<>
{error && <Alert severity="error" className="capitalize" sx={{ mb: 2 }}>{error}</Alert>}
<Card variant="outlined">
<Collapse in={loading}>
<LinearProgress />
</Collapse>
<Collapse in={searchParams.has("redirect_uri")}>
<Alert severity="info" sx={{ mb: 2 }}>
You need sign in before take an action. After that, we will take you back to your work.
</Alert>
</Collapse>
<CardContent
style={{ padding: "40px 48px 36px" }}
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center"
}}
>
{elements[panel]}
</CardContent>
<Card variant="outlined">
<Collapse in={loading}>
<LinearProgress />
</Collapse>
<Collapse in={challenge != null} unmountOnExit>
<Box>
<Paper square sx={{ pt: 3, px: 5, textAlign: "center" }}>
<Typography sx={{ mb: 2 }}>
Risk <b className="font-mono">{challenge?.risk_level}</b>&nbsp;
Progress <b className="font-mono">{challenge?.progress}/{challenge?.requirements}</b>
</Typography>
<LinearProgress
variant="determinate"
value={challenge?.progress / challenge?.requirements * 100}
sx={{ width: "calc(100%+5rem)", mt: 1, mx: -5 }}
/>
</Paper>
</Box>
</Collapse>
</Card>
<CardContent
style={{ padding: "40px 48px 36px" }}
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center"
}}
>
{elements[panel]}
</CardContent>
<Grid container justifyContent="center" sx={{ mt: 2 }}>
<Grid item>
<Link component={RouterLink} to="/auth/sign-up" variant="body2">
Haven't an account? Sign up!
</Link>
</Grid>
<Collapse in={challenge != null} unmountOnExit>
<Box>
<Paper square sx={{ pt: 3, px: 5, textAlign: "center" }}>
<Typography sx={{ mb: 2 }}>
Risk <b className="font-mono">{challenge?.risk_level}</b>&nbsp;
Progress <b className="font-mono">{challenge?.progress}/{challenge?.requirements}</b>
</Typography>
<LinearProgress
variant="determinate"
value={challenge?.progress / challenge?.requirements * 100}
sx={{ width: "calc(100%+5rem)", mt: 1, mx: -5 }}
/>
</Paper>
</Box>
</Collapse>
</Card>
<Grid container justifyContent="center" sx={{ mt: 2 }}>
<Grid item>
<Link component={RouterLink} to="/auth/sign-up" variant="body2">
Haven't an account? Sign up!
</Link>
</Grid>
</Box>
</Box>
</Grid>
</>
);
}

View File

@ -166,35 +166,33 @@ export default function SignUpPage() {
];
return (
<Box sx={{ height: "100vh", display: "flex", alignItems: "center", justifyContent: "center" }}>
<Box style={{ width: "100vw", maxWidth: "450px" }}>
{error && <Alert severity="error" className="capitalize" sx={{ mb: 2 }}>{error}</Alert>}
<>
{error && <Alert severity="error" className="capitalize" sx={{ mb: 2 }}>{error}</Alert>}
<Card variant="outlined">
<Collapse in={loading}>
<LinearProgress />
</Collapse>
<Card variant="outlined">
<Collapse in={loading}>
<LinearProgress />
</Collapse>
<CardContent
style={{ padding: "40px 48px 36px" }}
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center"
}}
>
{!done ? elements[0] : elements[1]}
</CardContent>
</Card>
<CardContent
style={{ padding: "40px 48px 36px" }}
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center"
}}
>
{!done ? elements[0] : elements[1]}
</CardContent>
</Card>
<Grid container justifyContent="center" sx={{ mt: 2 }}>
<Grid item>
<Link component={RouterLink} to="/auth/sign-in" variant="body2">
Already have an account? Sign in!
</Link>
</Grid>
<Grid container justifyContent="center" sx={{ mt: 2 }}>
<Grid item>
<Link component={RouterLink} to="/auth/sign-in" variant="body2">
Already have an account? Sign in!
</Link>
</Grid>
</Box>
</Box>
</Grid>
</>
);
}

View File

@ -0,0 +1,29 @@
import { useEffect } from "react";
import { Box, CircularProgress } from "@mui/material";
import { Outlet, useLocation, useNavigate } from "react-router-dom";
import { useUserinfo } from "@/stores/userinfo.tsx";
export default function AuthGuard() {
const { userinfo } = useUserinfo();
const navigate = useNavigate();
const location = useLocation();
useEffect(() => {
console.log(userinfo)
if (userinfo?.isReady) {
if (!userinfo?.isLoggedIn) {
const callback = location.pathname + location.search;
navigate({ pathname: "/auth/sign-in", search: `redirect_uri=${callback}` });
}
}
}, [userinfo]);
return !userinfo?.isReady ? (
<Box sx={{ pt: 32, display: "flex", justifyContent: "center", alignItems: "center" }}>
<Box>
<CircularProgress />
</Box>
</Box>
) : <Outlet />;
}

View File

@ -3,15 +3,17 @@ import { request } from "../scripts/request.ts";
import { createContext, useContext, useState } from "react";
export interface Userinfo {
isReady: boolean,
isLoggedIn: boolean,
displayName: string,
data: any,
}
const defaultUserinfo: Userinfo = {
isReady: false,
isLoggedIn: false,
displayName: "Citizen",
data: null,
data: null
};
const UserinfoContext = createContext<any>({ userinfo: defaultUserinfo });
@ -28,10 +30,15 @@ export function UserinfoProvider(props: any) {
}
async function readProfiles() {
if (!checkLoggedIn()) return;
if (!checkLoggedIn()) {
setUserinfo((data) => {
data.isReady = true;
return data;
});
}
const res = await request("/api/users/me", {
credentials: "include"
headers: { "Authorization": `Bearer ${getAtk()}` }
});
if (res.status !== 200) {
@ -42,6 +49,7 @@ export function UserinfoProvider(props: any) {
const data = await res.json();
setUserinfo({
isReady: true,
isLoggedIn: true,
displayName: data["nick"],
data: data