From a6cc450ea7dab5514fd892d6d733af9374c4f1da Mon Sep 17 00:00:00 2001 From: Khairul Hidayat Date: Sun, 17 Nov 2024 17:54:41 +0700 Subject: [PATCH] feat: add github oauth --- .env.example | 4 + frontend/app.config.ts | 42 ------ frontend/app.json | 41 ++++++ frontend/components/containers/drawer.tsx | 2 +- .../containers/user-menu-button.tsx | 30 ++--- frontend/components/ui/avatar.tsx | 25 ++++ frontend/hooks/useServerConfig.ts | 19 +++ frontend/package.json | 2 + .../pages/auth/components/login-github.tsx | 42 ++++++ frontend/pages/auth/hooks.ts | 17 +++ frontend/pages/auth/login.tsx | 18 ++- .../pages/team/components/member-list.tsx | 9 +- frontend/pnpm-lock.yaml | 45 +++++++ go.mod | 1 + go.sum | 4 + server/app/app.go | 8 +- server/app/auth/oauth_github.go | 124 ++++++++++++++++++ server/app/auth/repository.go | 6 + server/app/auth/router.go | 4 + server/app/server/router.go | 29 ++++ server/db/models.go | 1 + server/models/user.go | 20 ++- 22 files changed, 419 insertions(+), 74 deletions(-) delete mode 100644 frontend/app.config.ts create mode 100644 frontend/app.json create mode 100644 frontend/components/ui/avatar.tsx create mode 100644 frontend/hooks/useServerConfig.ts create mode 100644 frontend/pages/auth/components/login-github.tsx create mode 100644 server/app/auth/oauth_github.go create mode 100644 server/app/server/router.go diff --git a/.env.example b/.env.example index 597ba24..be5ef94 100644 --- a/.env.example +++ b/.env.example @@ -3,3 +3,7 @@ DATABASE_URL= # Generate a 32-byte (256-bit) key for AES-256 encryption using `openssl rand -hex 32` ENCRYPTION_KEY="" + +# OAuth client +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= diff --git a/frontend/app.config.ts b/frontend/app.config.ts deleted file mode 100644 index ba89900..0000000 --- a/frontend/app.config.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { ExpoConfig, ConfigContext } from "expo/config"; - -export default ({ config }: ConfigContext): ExpoConfig => ({ - ...config, - name: "Vaulterm", - slug: "vaulterm", - version: "1.0.0", - orientation: "portrait", - icon: "./assets/images/icon.png", - scheme: "vaulterm", - userInterfaceStyle: "automatic", - newArchEnabled: true, - splash: { - image: "./assets/images/splash.png", - resizeMode: "contain", - backgroundColor: "#ffffff", - }, - ios: { - supportsTablet: true, - }, - android: { - package: "sh.rul.vaulterm", - adaptiveIcon: { - foregroundImage: "./assets/images/adaptive-icon.png", - backgroundColor: "#ffffff", - }, - }, - web: { - bundler: "metro", - output: "single", - favicon: "./assets/images/favicon.png", - }, - plugins: ["expo-router"], - experiments: { - typedRoutes: true, - }, - extra: { - eas: { - projectId: "3e0112c1-f0ed-423c-b5cf-95633f23f6dc", - }, - }, -}); diff --git a/frontend/app.json b/frontend/app.json new file mode 100644 index 0000000..d8a497f --- /dev/null +++ b/frontend/app.json @@ -0,0 +1,41 @@ +{ + "name": "Vaulterm", + "slug": "vaulterm", + "version": "1.0.0", + "orientation": "portrait", + "icon": "./assets/images/icon.png", + "scheme": "vaulterm", + "userInterfaceStyle": "automatic", + "newArchEnabled": true, + "splash": { + "image": "./assets/images/splash.png", + "resizeMode": "contain", + "backgroundColor": "#ffffff" + }, + "ios": { + "supportsTablet": true + }, + "android": { + "package": "sh.rul.vaulterm", + "adaptiveIcon": { + "foregroundImage": "./assets/images/adaptive-icon.png", + "backgroundColor": "#ffffff" + } + }, + "web": { + "bundler": "metro", + "output": "single", + "favicon": "./assets/images/favicon.png" + }, + "plugins": [ + "expo-router" + ], + "experiments": { + "typedRoutes": true + }, + "extra": { + "eas": { + "projectId": "3e0112c1-f0ed-423c-b5cf-95633f23f6dc" + } + } +} \ No newline at end of file diff --git a/frontend/components/containers/drawer.tsx b/frontend/components/containers/drawer.tsx index 16c9034..da6f6a2 100644 --- a/frontend/components/containers/drawer.tsx +++ b/frontend/components/containers/drawer.tsx @@ -24,7 +24,7 @@ const Drawer = (props: DrawerContentComponentProps) => { return ( - + diff --git a/frontend/components/containers/user-menu-button.tsx b/frontend/components/containers/user-menu-button.tsx index 05ca444..d48f969 100644 --- a/frontend/components/containers/user-menu-button.tsx +++ b/frontend/components/containers/user-menu-button.tsx @@ -1,18 +1,11 @@ import React from "react"; -import { - Avatar, - Button, - ListItem, - Separator, - Text, - useMedia, - View, -} from "tamagui"; +import { Button, ListItem, Separator, Text, useMedia, View } from "tamagui"; import MenuButton from "../ui/menu-button"; import Icons from "../ui/icons"; import { logout, setTeam, useTeamId } from "@/stores/auth"; import { useUser } from "@/hooks/useUser"; import TeamForm, { teamFormModal } from "@/pages/team/components/team-form"; +import Avatar from "../ui/avatar"; const UserMenuButton = () => { const user = useUser(); @@ -31,16 +24,21 @@ const UserMenuButton = () => { borderWidth={0} justifyContent="flex-start" borderRadius="$10" - py={0} - px="$2" + p={0} gap="$1" > - - - + + - {user?.name} - + + {user?.name} + + {team ? `${team.icon} ${team.name}` : "Personal"} diff --git a/frontend/components/ui/avatar.tsx b/frontend/components/ui/avatar.tsx new file mode 100644 index 0000000..89ee2d1 --- /dev/null +++ b/frontend/components/ui/avatar.tsx @@ -0,0 +1,25 @@ +import React from "react"; +import { GetProps, Avatar as BaseAvatar } from "tamagui"; +import Icons from "./icons"; + +type AvatarProps = GetProps & { + src?: string; + $image?: GetProps; +}; + +const Avatar = ({ src, $image, ...props }: AvatarProps) => { + return ( + + {src ? : null} + + + + + ); +}; + +export default Avatar; diff --git a/frontend/hooks/useServerConfig.ts b/frontend/hooks/useServerConfig.ts new file mode 100644 index 0000000..b02f6e1 --- /dev/null +++ b/frontend/hooks/useServerConfig.ts @@ -0,0 +1,19 @@ +import api from "@/lib/api"; +import { useQuery } from "@tanstack/react-query"; + +export const useServerConfig = (configName?: string | string[]) => { + return useQuery({ + queryKey: ["server/config", configName], + queryFn: () => { + const keys = Array.isArray(configName) + ? configName.join(",") + : configName; + return api("/server/config", { params: { keys } }); + }, + select: (data) => { + return typeof configName === "string" + ? (data[configName] as string) + : data; + }, + }); +}; diff --git a/frontend/package.json b/frontend/package.json index e83f839..d51d3c9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -31,8 +31,10 @@ "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", "expo": "~52.0.6", + "expo-auth-session": "~6.0.0", "expo-blur": "~14.0.1", "expo-constants": "~17.0.3", + "expo-crypto": "~14.0.1", "expo-font": "~13.0.0", "expo-haptics": "~14.0.0", "expo-linking": "~7.0.2", diff --git a/frontend/pages/auth/components/login-github.tsx b/frontend/pages/auth/components/login-github.tsx new file mode 100644 index 0000000..14a92ca --- /dev/null +++ b/frontend/pages/auth/components/login-github.tsx @@ -0,0 +1,42 @@ +import React, { useEffect, useMemo } from "react"; +import { makeRedirectUri, useAuthRequest } from "expo-auth-session"; +import appConfig from "@/app.json"; +import { Button } from "tamagui"; +import { useOAuthCallback } from "../hooks"; +import { useServerConfig } from "@/hooks/useServerConfig"; + +const LoginGithubButton = () => { + const { data: clientId } = useServerConfig("github_client_id"); + const discovery = useMemo(() => { + return { + authorizationEndpoint: "https://github.com/login/oauth/authorize", + tokenEndpoint: "https://github.com/login/oauth/access_token", + revocationEndpoint: `https://github.com/settings/connections/applications/${clientId}`, + }; + }, [clientId]); + const oauth = useOAuthCallback("github"); + + const [request, response, promptAsync] = useAuthRequest( + { + clientId: clientId, + scopes: ["identity"], + redirectUri: makeRedirectUri({ scheme: appConfig.scheme }), + }, + discovery + ); + + useEffect(() => { + if (response?.type === "success") { + const { code } = response.params; + oauth.mutate(code); + } + }, [response]); + + return ( + + ); +}; + +export default LoginGithubButton; diff --git a/frontend/pages/auth/hooks.ts b/frontend/pages/auth/hooks.ts index 29281ff..7119a31 100644 --- a/frontend/pages/auth/hooks.ts +++ b/frontend/pages/auth/hooks.ts @@ -42,3 +42,20 @@ export const useRegisterMutation = () => { }, }); }; + +export const useOAuthCallback = (type: string) => { + return useMutation({ + mutationFn: async (code: string) => { + const res = await api(`/auth/oauth/${type}/callback?code=${code}`); + const { data } = loginResultSchema.safeParse(res); + if (!data) { + throw new Error("Invalid response!"); + } + return data; + }, + onSuccess(data) { + authStore.setState({ token: data.sessionId }); + router.replace("/"); + }, + }); +}; diff --git a/frontend/pages/auth/login.tsx b/frontend/pages/auth/login.tsx index 1b10da8..9597a96 100644 --- a/frontend/pages/auth/login.tsx +++ b/frontend/pages/auth/login.tsx @@ -6,17 +6,21 @@ import { useZForm } from "@/hooks/useZForm"; import { Link, router, Stack } from "expo-router"; import Button from "@/components/ui/button"; import ThemeSwitcher from "@/components/containers/theme-switcher"; -import { useMutation } from "@tanstack/react-query"; -import { z } from "zod"; +import * as WebBrowser from "expo-web-browser"; import { ErrorAlert } from "@/components/ui/alert"; import { loginSchema } from "./schema"; import Icons from "@/components/ui/icons"; import tamaguiConfig from "@/tamagui.config"; import { useLoginMutation } from "./hooks"; +import LoginGithubButton from "./components/login-github"; +import { useServerConfig } from "@/hooks/useServerConfig"; + +WebBrowser.maybeCompleteAuthSession(); export default function LoginPage() { const form = useZForm(loginSchema); const login = useLoginMutation(); + const { data: oauthList } = useServerConfig("oauth"); const onSubmit = form.handleSubmit((values) => { login.mutate(values); @@ -98,6 +102,16 @@ export default function LoginPage() { + {oauthList?.length > 0 && ( + <> + + or + + + {oauthList.includes("github") && } + + )} +