Skip to content

Commit d8d3a98

Browse files
committed
feat: OAuth through GitHub and Google
https://harperdb.atlassian.net/browse/STUDIO-581
1 parent 9791eaa commit d8d3a98

File tree

10 files changed

+298
-12
lines changed

10 files changed

+298
-12
lines changed

src/features/auth/CheckOAuth.tsx

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { currentUserQueryKey, getCurrentUser } from '@/features/auth/queries/getCurrentUser';
2+
import { authStore, OverallAppSignIn } from '@/features/auth/store/authStore';
3+
import { loginSuccessDatadogAction } from '@/integrations/datadog/datadog';
4+
import { reoClient } from '@/integrations/reo/reo';
5+
import { parseCompanyFromEmail } from '@/lib/string/parseCompanyFromEmail';
6+
import { getDefaultSignedInCloudRouteForUser } from '@/lib/urls/getDefaultSignedInCloudRouteForUser';
7+
import { useQueryClient } from '@tanstack/react-query';
8+
import { Link, useNavigate, useRouter, useSearch } from '@tanstack/react-router';
9+
import { useEffect } from 'react';
10+
import { toast } from 'sonner';
11+
12+
let checking: Promise<void> | null = null;
13+
14+
export function CheckOAuth() {
15+
const navigate = useNavigate();
16+
const router = useRouter();
17+
const queryClient = useQueryClient();
18+
const { redirect } = useSearch({ strict: false });
19+
20+
useEffect(() => {
21+
if (checking) {
22+
return;
23+
}
24+
checking = (async function() {
25+
const user = await getCurrentUser().catch(() => null);
26+
if (!user) {
27+
toast.error('We were not able to verify your sign-in. Please try signing in again.', {
28+
duration: 10_000,
29+
});
30+
await navigate({ to: '/sign-in' });
31+
} else {
32+
authStore.setUserForEntity(OverallAppSignIn, user);
33+
const defaultCloudRoute = getDefaultSignedInCloudRouteForUser(user);
34+
35+
loginSuccessDatadogAction(user);
36+
37+
const company = parseCompanyFromEmail(user.email);
38+
reoClient?.identify?.({
39+
username: user.email,
40+
type: 'email',
41+
...(company ? { company } : {}),
42+
});
43+
await queryClient.invalidateQueries({ queryKey: currentUserQueryKey, refetchType: 'none' });
44+
await router.invalidate();
45+
await navigate({ to: redirect?.startsWith('/') ? redirect : defaultCloudRoute });
46+
}
47+
48+
checking = null;
49+
})();
50+
// We only want this to fire once.
51+
// eslint-disable-next-line react-hooks/exhaustive-deps
52+
}, []);
53+
54+
return (
55+
<div className="text-white w-lg flex flex-col gap-4">
56+
<h1 className="text-3xl font-light">
57+
<span
58+
aria-hidden="true"
59+
className="text-2xl animate-flower-dance mr-4"
60+
title="Loading"
61+
>🌼</span>
62+
Checking...
63+
</h1>
64+
65+
<div className="underline flex gap-4">
66+
<Link className="text-sm opacity-50 hover:text-blue-300" to="/sign-in">
67+
Try signing in again
68+
</Link>
69+
</div>
70+
</div>
71+
);
72+
}

src/features/auth/SignIn.tsx

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import { EmailSignInSchema } from '@/features/instance/operations/schemas/signIn
1010
import { zodResolver } from '@hookform/resolvers/zod';
1111
import { Link, useSearch } from '@tanstack/react-router';
1212
import { useForm } from 'react-hook-form';
13+
import { GitHubAuthenticationButton } from './components/GitHubAuthenticationButton';
14+
import { GoogleAuthenticationButton } from './components/GoogleAuthenticationButton';
1315
import { useCloudSignIn } from './hooks/useCloudSignIn';
1416

1517
export function SignIn() {
@@ -70,15 +72,22 @@ export function SignIn() {
7072
<Button type="submit" variant="submit" className="w-full my-2 rounded-full" disabled={isPending}>
7173
Sign In
7274
</Button>
75+
<div className="flex px-4 mt-4 underline place-content-between">
76+
<Link className="text-sm hover:text-blue-300" to="/sign-up" search={{ me: email }}>
77+
Sign up for free
78+
</Link>
79+
<Link className="text-sm hover:text-blue-300" to="/forgot-password" search={{ me: email }}>
80+
Forgot password?
81+
</Link>
82+
</div>
7383
</form>
7484
</Form>
75-
<div className="flex px-4 mt-4 underline place-content-between">
76-
<Link className="text-sm hover:text-blue-300" to="/sign-up" search={{ me: email }}>
77-
Sign up for free
78-
</Link>
79-
<Link className="text-sm hover:text-blue-300" to="/forgot-password" search={{ me: email }}>
80-
Forgot password?
81-
</Link>
85+
86+
<hr className="border-gray-600 my-6" />
87+
88+
<div className="flex flex-col gap-2">
89+
<GoogleAuthenticationButton text="Sign in with Google" />
90+
<GitHubAuthenticationButton text="Sign in with GitHub" />
8291
</div>
8392
</div>
8493
);

src/features/auth/SignUp.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import { Link, useNavigate, useSearch } from '@tanstack/react-router';
1515
import { useCallback, useEffect } from 'react';
1616
import { useForm } from 'react-hook-form';
1717
import { z } from 'zod';
18+
import { GitHubAuthenticationButton } from './components/GitHubAuthenticationButton';
19+
import { GoogleAuthenticationButton } from './components/GoogleAuthenticationButton';
1820
import { useSignUpMutation } from './hooks/useSignUp';
1921

2022
const SignUpSchema = z.object({
@@ -83,6 +85,14 @@ export function SignUp() {
8385
return (
8486
<div className="text-white w-xs">
8587
<h2 className="text-2xl font-light">Sign up for Harper Fabric</h2>
88+
89+
<div className="flex flex-col gap-2 my-6">
90+
<GoogleAuthenticationButton text="Sign up with Google" />
91+
<GitHubAuthenticationButton text="Sign up with GitHub" />
92+
</div>
93+
94+
<hr className="border-gray-600" />
95+
8696
<Form {...methods}>
8797
<form onSubmit={handleSubmit(submitForm)} className="grid gap-4 my-4">
8898
<FormField
@@ -183,7 +193,7 @@ export function SignUp() {
183193
Terms of Service
184194
</a></p>
185195

186-
<Button type="submit" variant="submit" className="w-full my-2 rounded-full">
196+
<Button type="submit" variant="submit" className="w-full rounded-full my-4">
187197
Sign Up For Free
188198
</Button>
189199
</form>
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
2+
.github-signin-btn {
3+
-moz-user-select: none;
4+
-webkit-user-select: none;
5+
-ms-user-select: none;
6+
-webkit-appearance: none;
7+
background-color: hsl(0, 0%, 6.67%);
8+
border: 1px solid #000;
9+
-webkit-box-sizing: border-box;
10+
box-sizing: border-box;
11+
-webkit-border-radius: 20px;
12+
border-radius: 20px;
13+
cursor: pointer;
14+
font-family: 'Roboto', arial, sans-serif;
15+
font-size: 14px;
16+
height: 40px;
17+
letter-spacing: 0.25px;
18+
outline: none;
19+
overflow: hidden;
20+
padding: 0 12px;
21+
position: relative;
22+
text-align: center;
23+
-webkit-transition: background-color .218s, border-color .218s, box-shadow .218s;
24+
transition: background-color .218s, border-color .218s, box-shadow .218s;
25+
vertical-align: middle;
26+
white-space: nowrap;
27+
width: 100%;
28+
max-width: 400px;
29+
min-width: min-content;
30+
31+
display: flex;
32+
align-items: center;
33+
justify-content: center;
34+
color: #fff;
35+
text-decoration: none;
36+
37+
&:hover {
38+
background-color: hsl(0, 0%, 12.94%);
39+
}
40+
41+
.github-icon {
42+
width: 20px;
43+
height: 20px;
44+
margin-right: 12px;
45+
}
46+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import './GitHubAuthenticationButton.css';
2+
import { GithubIcon } from 'lucide-react';
3+
4+
export function GitHubAuthenticationButton({ text }: { text: 'Sign in with GitHub' | 'Sign up with GitHub' }) {
5+
return <a href="/oauth/github/login?redirect=%2F%23%2Fcheck-oauth">
6+
<button className="github-signin-btn">
7+
<GithubIcon className="github-icon" />
8+
{text}
9+
</button>
10+
</a>;
11+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
.gsi-material-button {
2+
-moz-user-select: none;
3+
-webkit-user-select: none;
4+
-ms-user-select: none;
5+
-webkit-appearance: none;
6+
background-color: #fff;
7+
background-image: none;
8+
border: 1px solid #747775;
9+
-webkit-border-radius: 20px;
10+
border-radius: 20px;
11+
-webkit-box-sizing: border-box;
12+
box-sizing: border-box;
13+
color: #1f1f1f;
14+
cursor: pointer;
15+
font-family: 'Roboto', arial, sans-serif;
16+
font-size: 14px;
17+
height: 40px;
18+
letter-spacing: 0.25px;
19+
outline: none;
20+
overflow: hidden;
21+
padding: 0 12px;
22+
position: relative;
23+
text-align: center;
24+
-webkit-transition: background-color .218s, border-color .218s, box-shadow .218s;
25+
transition: background-color .218s, border-color .218s, box-shadow .218s;
26+
vertical-align: middle;
27+
white-space: nowrap;
28+
width: 100%;
29+
max-width: 400px;
30+
min-width: min-content;
31+
}
32+
33+
.gsi-material-button .gsi-material-button-icon {
34+
height: 20px;
35+
margin-right: 12px;
36+
min-width: 20px;
37+
width: 20px;
38+
}
39+
40+
.gsi-material-button .gsi-material-button-content-wrapper {
41+
-webkit-align-items: center;
42+
align-items: center;
43+
display: flex;
44+
-webkit-flex-direction: row;
45+
flex-direction: row;
46+
-webkit-flex-wrap: nowrap;
47+
flex-wrap: nowrap;
48+
height: 100%;
49+
justify-content: center;
50+
position: relative;
51+
width: 100%;
52+
}
53+
54+
.gsi-material-button .gsi-material-button-contents {
55+
-webkit-flex-grow: 0;
56+
flex-grow: 0;
57+
font-family: 'Roboto', arial, sans-serif;
58+
font-weight: 500;
59+
overflow: hidden;
60+
text-overflow: ellipsis;
61+
vertical-align: top;
62+
}
63+
64+
.gsi-material-button .gsi-material-button-state {
65+
-webkit-transition: opacity .218s;
66+
transition: opacity .218s;
67+
bottom: 0;
68+
left: 0;
69+
opacity: 0;
70+
position: absolute;
71+
right: 0;
72+
top: 0;
73+
}
74+
75+
.gsi-material-button:disabled {
76+
cursor: default;
77+
background-color: #ffffff61;
78+
border-color: #1f1f1f1f;
79+
}
80+
81+
.gsi-material-button:disabled .gsi-material-button-contents {
82+
opacity: 38%;
83+
}
84+
85+
.gsi-material-button:disabled .gsi-material-button-icon {
86+
opacity: 38%;
87+
}
88+
89+
.gsi-material-button:not(:disabled):active .gsi-material-button-state,
90+
.gsi-material-button:not(:disabled):focus .gsi-material-button-state {
91+
background-color: #303030;
92+
opacity: 12%;
93+
}
94+
95+
.gsi-material-button:not(:disabled):hover {
96+
-webkit-box-shadow: 0 1px 2px 0 rgba(60, 64, 67, .30), 0 1px 3px 1px rgba(60, 64, 67, .15);
97+
box-shadow: 0 1px 2px 0 rgba(60, 64, 67, .30), 0 1px 3px 1px rgba(60, 64, 67, .15);
98+
}
99+
100+
.gsi-material-button:not(:disabled):hover .gsi-material-button-state {
101+
background-color: #303030;
102+
opacity: 8%;
103+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import './GoogleAuthenticationButton.css';
2+
3+
export function GoogleAuthenticationButton({ text }: { text: 'Sign in with Google' | 'Sign up with Google' }) {
4+
return <a href="/oauth/google/login?redirect=%2F%23%2Fcheck-oauth">
5+
<button className="gsi-material-button">
6+
<div className="gsi-material-button-state"></div>
7+
<div className="gsi-material-button-content-wrapper">
8+
<div className="gsi-material-button-icon">
9+
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" xmlnsXlink="http://www.w3.org/1999/xlink" className="block">
10+
<path fill="#EA4335" d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"></path>
11+
<path fill="#4285F4" d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z"></path>
12+
<path fill="#FBBC05" d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z"></path>
13+
<path fill="#34A853" d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z"></path>
14+
<path fill="none" d="M0 0h48v48H0z"></path>
15+
</svg>
16+
</div>
17+
<span className="gsi-material-button-contents">{text}</span>
18+
<span className="hidden">{text}</span>
19+
</div>
20+
</button>
21+
</a>;
22+
}

src/features/auth/hooks/useCloudSignIn.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ export function useCloudSignIn() {
2323
const submitForm = useCallback((formData: z.infer<typeof EmailSignInSchema>) => {
2424
submitLoginData(formData, {
2525
onSuccess: async (data) => {
26-
// TODO: Detect and fallback to session storage and basic auth if necessary.
2726
authStore.setUserForEntity(OverallAppSignIn, data);
2827
const defaultCloudRoute = getDefaultSignedInCloudRouteForUser(data);
2928

src/features/auth/routes.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { getDefaultSignedInCloudRouteForUser } from '@/lib/urls/getDefaultSigned
33
import { rootRoute } from '@/router/rootRoute';
44
import { createRoute, redirect } from '@tanstack/react-router';
55
import { AuthLayout } from './AuthLayout';
6+
import { CheckOAuth } from './CheckOAuth';
67
import { ClusterInstanceSignIn } from './ClusterInstanceSignIn';
78
import { ForgotPassword } from './ForgotPassword';
89
import { ResetPassword } from './ResetPassword';
@@ -67,6 +68,13 @@ const verifyingEmailRoute = createRoute({
6768
component: Verifying,
6869
});
6970

71+
const checkOAuthRoute = createRoute({
72+
getParentRoute: () => authLayout,
73+
path: 'check-oauth',
74+
component: CheckOAuth,
75+
});
76+
77+
7078
const resetPasswordRoute = createRoute({
7179
getParentRoute: () => authLayout,
7280
path: 'reset-password',
@@ -77,6 +85,7 @@ export const authRouteTree =
7785
authLayout.addChildren([
7886
signInRoute,
7987
signUpRoute,
88+
checkOAuthRoute,
8089
forgotPasswordRoute,
8190
verifyEmailRoute,
8291
verifyingEmailRoute,

0 commit comments

Comments
 (0)