feat: oauth2 登录

This commit is contained in:
naiba
2024-12-28 23:50:24 +08:00
parent fbf931293e
commit 97a5deb648
12 changed files with 926 additions and 753 deletions

View File

@@ -6,7 +6,7 @@ interface CommonResponse<T> {
function buildUrl(path: string, data?: any): string { function buildUrl(path: string, data?: any): string {
if (!data) return path if (!data) return path
const url = new URL(path) const url = new URL(window.location.origin + path)
for (const key in data) { for (const key in data) {
url.searchParams.append(key, data[key]) url.searchParams.append(key, data[key])
} }

33
src/api/oauth2.ts Normal file
View File

@@ -0,0 +1,33 @@
import { ModelOauth2LoginResponse } from "@/types"
import { FetcherMethod, fetcher } from "./api"
export enum Oauth2RequestType {
LOGIN = 1,
BIND = 2,
}
export const getOauth2RedirectURL = async (provider: string, rType: Oauth2RequestType): Promise<ModelOauth2LoginResponse> => {
return fetcher<ModelOauth2LoginResponse>(FetcherMethod.GET, `/api/v1/oauth2/${provider}`, {
"type": rType,
})
}
export const bindOauth2 = async (provider: string, state: string, code: string): Promise<ModelOauth2LoginResponse> => {
return fetcher<ModelOauth2LoginResponse>(FetcherMethod.POST, `/api/v1/oauth2/${provider}/bind`, {
"state": state,
"code": code,
})
}
export const unbindOauth2 = async (provider: string): Promise<ModelOauth2LoginResponse> => {
return fetcher<ModelOauth2LoginResponse>(FetcherMethod.POST, `/api/v1/oauth2/${provider}/unbind`)
}
export const oauth2callback = async (provider: string, state: string, code: string): Promise<void> => {
return fetcher<void>(FetcherMethod.POST, `/api/v1/oauth2/${provider}/callback`, {
state, code
})
}

View File

@@ -8,7 +8,7 @@ import {
import { useAuth } from "@/hooks/useAuth" import { useAuth } from "@/hooks/useAuth"
import useSettings from "@/hooks/useSetting" import useSettings from "@/hooks/useSetting"
import { copyToClipboard } from "@/lib/utils" import { copyToClipboard } from "@/lib/utils"
import { ModelProfile, ModelSettingResponse } from "@/types" import { ModelProfile, ModelConfig } from "@/types"
import i18next from "i18next" import i18next from "i18next"
import { Check, Clipboard } from "lucide-react" import { Check, Clipboard } from "lucide-react"
import { forwardRef, useState } from "react" import { forwardRef, useState } from "react"
@@ -33,8 +33,8 @@ export const InstallCommandsMenu = forwardRef<HTMLButtonElement, ButtonProps>((p
try { try {
setCopy(true) setCopy(true)
if (!profile) throw new Error("Profile is not found.") if (!profile) throw new Error("Profile is not found.")
if (!settings) throw new Error("Settings is not found.") if (!settings?.config) throw new Error("Settings is not found.")
await copyToClipboard(generateCommand(type, settings, profile) || "") await copyToClipboard(generateCommand(type, settings!.config, profile) || "")
} catch (e: Error | any) { } catch (e: Error | any) {
console.error(e) console.error(e)
toast(t("Error"), { toast(t("Error"), {
@@ -88,7 +88,7 @@ export const InstallCommandsMenu = forwardRef<HTMLButtonElement, ButtonProps>((p
const generateCommand = ( const generateCommand = (
type: number, type: number,
{ agent_secret_key, install_host, tls }: ModelSettingResponse, { agent_secret_key, install_host, tls }: ModelConfig,
{ agent_secret, role }: ModelProfile, { agent_secret, role }: ModelProfile,
) => { ) => {
if (!install_host) throw new Error(i18next.t("Results.InstallHostRequired")) if (!install_host) throw new Error(i18next.t("Results.InstallHostRequired"))

View File

@@ -62,7 +62,7 @@ export const ServerCard: React.FC<ServerCardProps> = ({ data, mutate }) => {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const onSubmit = async (values: z.infer<typeof serverFormSchema>) => { const onSubmit = async (values: z.infer<typeof serverFormSchema>) => {
await updateServer(data.id, values) await updateServer(data!.id!, values)
setOpen(false) setOpen(false)
await mutate() await mutate()
form.reset() form.reset()
@@ -122,7 +122,6 @@ export const ServerCard: React.FC<ServerCardProps> = ({ data, mutate }) => {
{...field} {...field}
value={conv.arrToStr(field.value || [])} value={conv.arrToStr(field.value || [])}
onChange={(e) => { onChange={(e) => {
console.log(field.value)
const arr = conv const arr = conv
.strToArr(e.target.value) .strToArr(e.target.value)
.map(Number) .map(Number)

View File

@@ -5,10 +5,12 @@ import { useNavigate } from "react-router-dom"
import { toast } from "sonner" import { toast } from "sonner"
import { useMainStore } from "./useMainStore" import { useMainStore } from "./useMainStore"
import { oauth2callback } from "@/api/oauth2"
const AuthContext = createContext<AuthContextProps>({ const AuthContext = createContext<AuthContextProps>({
profile: undefined, profile: undefined,
login: () => { }, login: () => { },
loginOauth2: () => { },
logout: () => { }, logout: () => { },
}) })
@@ -43,6 +45,20 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
} }
} }
const loginOauth2 = async (provider: string, state: string, code: string) => {
try {
await oauth2callback(provider, state, code)
const user = await getProfile()
user.role = user.role || 0
setProfile(user)
navigate("/dashboard")
} catch (error: any) {
toast(error.message)
} finally {
window.history.replaceState({}, document.title, window.location.pathname)
}
}
const logout = () => { const logout = () => {
document.cookie.split(";").forEach(function (c) { document.cookie.split(";").forEach(function (c) {
document.cookie = c document.cookie = c
@@ -57,6 +73,7 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
() => ({ () => ({
profile, profile,
login, login,
loginOauth2,
logout, logout,
}), }),
[profile], [profile],

View File

@@ -1,7 +1,8 @@
import { swrFetcher } from "@/api/api" import { swrFetcher } from "@/api/api"
import { ModelSettingResponse } from "@/types" import { GithubComNezhahqNezhaModelSettingResponseModelConfig } from "@/types"
import useSWR from "swr" import useSWR from "swr"
export default function useSetting() { export default function useSetting() {
return useSWR<ModelSettingResponse>("/api/v1/setting", swrFetcher) return useSWR<GithubComNezhahqNezhaModelSettingResponseModelConfig>("/api/v1/setting", swrFetcher)
} }

View File

@@ -1,3 +1,4 @@
import { getOauth2RedirectURL, Oauth2RequestType } from "@/api/oauth2"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { import {
Form, Form,
@@ -9,10 +10,13 @@ import {
} from "@/components/ui/form" } from "@/components/ui/form"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { useAuth } from "@/hooks/useAuth" import { useAuth } from "@/hooks/useAuth"
import useSetting from "@/hooks/useSetting"
import { zodResolver } from "@hookform/resolvers/zod" import { zodResolver } from "@hookform/resolvers/zod"
import i18next from "i18next" import i18next from "i18next"
import { useEffect } from "react"
import { useForm } from "react-hook-form" import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { toast } from "sonner"
import { z } from "zod" import { z } from "zod"
const formSchema = z.object({ const formSchema = z.object({
@@ -25,7 +29,17 @@ const formSchema = z.object({
}) })
function Login() { function Login() {
const { login } = useAuth() const { login, loginOauth2 } = useAuth()
const { data: settingData } = useSetting()
useEffect(() => {
const oauth2Code = new URLSearchParams(window.location.search).get("code")
const oauth2State = new URLSearchParams(window.location.search).get("state")
const oauth2Provider = new URLSearchParams(window.location.search).get("provider")
if (oauth2Code && oauth2State && oauth2Provider) {
loginOauth2(oauth2Provider, oauth2State, oauth2Code)
}
}, [window.location.search])
const form = useForm<z.infer<typeof formSchema>>({ const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
@@ -39,6 +53,15 @@ function Login() {
login(values.username, values.password) login(values.username, values.password)
} }
async function loginWith(provider: string) {
try {
const redirectUrl = await getOauth2RedirectURL(provider, Oauth2RequestType.LOGIN)
window.location.href = redirectUrl.redirect!
} catch (error: any) {
toast.error(error.message)
}
}
const { t } = useTranslation() const { t } = useTranslation()
return ( return (
@@ -79,6 +102,11 @@ function Login() {
<Button type="submit">{t("Login")}</Button> <Button type="submit">{t("Login")}</Button>
</form> </form>
</Form> </Form>
<div className="mt-4">
{settingData?.config?.oauth2_providers?.map((p: string) =>
<Button onClick={() => loginWith(p)}>{p}</Button>
)}
</div>
</div> </div>
) )
} }

View File

@@ -1,16 +1,62 @@
import { bindOauth2, getOauth2RedirectURL, Oauth2RequestType, unbindOauth2 } from "@/api/oauth2"
import { getProfile } from "@/api/user"
import { ProfileCard } from "@/components/profile" import { ProfileCard } from "@/components/profile"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { useMainStore } from "@/hooks/useMainStore" import { useMainStore } from "@/hooks/useMainStore"
import { useMediaQuery } from "@/hooks/useMediaQuery" import { useMediaQuery } from "@/hooks/useMediaQuery"
import { useServer } from "@/hooks/useServer" import { useServer } from "@/hooks/useServer"
import useSetting from "@/hooks/useSetting"
import { Boxes, Server } from "lucide-react" import { Boxes, Server } from "lucide-react"
import { useEffect } from "react"
import { toast } from "sonner"
export default function ProfilePage() { export default function ProfilePage() {
const { profile } = useMainStore() const { profile, setProfile } = useMainStore()
const { servers, serverGroups } = useServer() const { servers, serverGroups } = useServer()
const { data: settingData } = useSetting()
const isDesktop = useMediaQuery("(min-width: 890px)") const isDesktop = useMediaQuery("(min-width: 890px)")
useEffect(() => {
const oauth2Code = new URLSearchParams(window.location.search).get("code")
const oauth2State = new URLSearchParams(window.location.search).get("state")
const oauth2Provider = new URLSearchParams(window.location.search).get("provider")
if (oauth2Code && oauth2State && oauth2Provider) {
bindOauth2(oauth2Provider, oauth2State, oauth2Code)
.catch((error) => {
toast.error(error.message)
})
.then(() => {
getProfile().then((profile) => {
setProfile(profile)
})
}).finally(() => {
window.history.replaceState({}, document.title, window.location.pathname)
})
}
}, [window.location.search])
const bindO2 = async (provider: string) => {
try {
const redirectUrl = await getOauth2RedirectURL(provider, Oauth2RequestType.BIND)
window.location.href = redirectUrl.redirect!
} catch (error: any) {
toast.error(error.message)
}
}
const unbindO2 = async (provider: string) => {
try {
await unbindOauth2(provider)
getProfile().then((profile) => {
setProfile(profile)
})
} catch (error: any) {
toast.error(error.message)
}
}
return ( return (
profile && ( profile && (
<div className={`flex p-8 gap-4 ${isDesktop ? "ml-6" : "flex-col"}`}> <div className={`flex p-8 gap-4 ${isDesktop ? "ml-6" : "flex-col"}`}>
@@ -62,6 +108,22 @@ export default function ProfilePage() {
{serverGroups?.length || 0} {serverGroups?.length || 0}
</CardContent> </CardContent>
</Card> </Card>
<Card className="w-full">
<CardHeader>
<CardTitle className="flex gap-2 text-xl items-center">
<Boxes /> Oauth2 bindings
</CardTitle>
</CardHeader>
<CardContent className="text-lg font-semibold">
{settingData?.config?.oauth2_providers?.map((provider) => <div>
{provider}: {profile.oauth2_bind?.[provider.toLowerCase()]} {profile.oauth2_bind?.[provider.toLowerCase()] ?
<Button size="sm" onClick={() => unbindO2(provider)}>Unbind</Button>
:
<Button size="sm" onClick={() => bindO2(provider)}>Bind</Button>}
</div>)}
</CardContent>
</Card>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -13,14 +13,14 @@ export default function Root() {
const { data: settingData, error } = useSetting() const { data: settingData, error } = useSetting()
useEffect(() => { useEffect(() => {
document.title = settingData?.site_name || "哪吒监控 Nezha Monitoring" document.title = settingData?.config?.site_name || "哪吒监控 Nezha Monitoring"
}, [settingData?.site_name]) }, [settingData?.config?.site_name])
useEffect(() => { useEffect(() => {
if (settingData?.custom_code_dashboard) { if (settingData?.config?.custom_code_dashboard) {
InjectContext(settingData?.custom_code_dashboard) InjectContext(settingData?.config?.custom_code_dashboard)
} }
}, [settingData?.custom_code_dashboard]) }, [settingData?.config?.custom_code_dashboard])
if (error) { if (error) {
throw error throw error
@@ -30,8 +30,8 @@ export default function Root() {
return null return null
} }
if (settingData?.language && !localStorage.getItem("language")) { if (settingData?.config?.language && !localStorage.getItem("language")) {
i18n.changeLanguage(settingData?.language) i18n.changeLanguage(settingData?.config?.language)
} }
return ( return (

View File

@@ -68,10 +68,10 @@ export default function SettingsPage() {
defaultValues: config defaultValues: config
? { ? {
...config, ...config,
language: config.language, language: config?.config?.language,
site_name: config.site_name || "", site_name: config.config?.site_name || "",
user_template: user_template:
config.user_template || config.config?.user_template ||
Object.keys(config.frontend_templates?.filter((t) => !t.is_admin) || {})[0] || Object.keys(config.frontend_templates?.filter((t) => !t.is_admin) || {})[0] ||
"user-dist", "user-dist",
} }
@@ -88,10 +88,10 @@ export default function SettingsPage() {
}) })
useEffect(() => { useEffect(() => {
if (config) { if (config?.config) {
form.reset(config) form.reset(config?.config)
} }
}, [config, form]) }, [config?.config, form])
const onSubmit = async (values: z.infer<typeof settingFormSchema>) => { const onSubmit = async (values: z.infer<typeof settingFormSchema>) => {
try { try {
@@ -172,7 +172,7 @@ export default function SettingsPage() {
(t) => t.path === value, (t) => t.path === value,
) )
if (template) { if (template) {
form.setValue("user_template", template.path) form.setValue("user_template", template!.path!)
} }
}} }}
> >
@@ -188,7 +188,7 @@ export default function SettingsPage() {
) || [] ) || []
).map((template) => ( ).map((template) => (
<div key={template.path}> <div key={template.path}>
<SelectItem value={template.path}> <SelectItem value={template.path!}>
<div className="flex flex-col items-start gap-1"> <div className="flex flex-col items-start gap-1">
<div className="font-medium"> <div className="font-medium">
{template.name} {template.name}

File diff suppressed because it is too large Load Diff

View File

@@ -3,5 +3,6 @@ import { ModelProfile } from "@/types"
export interface AuthContextProps { export interface AuthContextProps {
profile: ModelProfile | undefined profile: ModelProfile | undefined
login: (username: string, password: string) => void login: (username: string, password: string) => void
loginOauth2: (provider: string, state: string, code: string) => void
logout: () => void logout: () => void
} }