Skip to content

Commit 2bb986d

Browse files
Add oAuth logins (#532)
* feat: oauth * fix: auth flows * fix: pull request feedbacks * fix: add production verification for REPLACE ME values in env vars * fix: remove unused variable * Update src/server/routers/oauth.tsx Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * fix: PR feedbacks * fix: move oauth config file * fix: toasts * docs: add comment for artic documentation link * fix: remove unused some --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
1 parent 6b6f3b2 commit 2bb986d

File tree

23 files changed

+949
-11
lines changed

23 files changed

+949
-11
lines changed

.env.example

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,18 @@ NEXT_PUBLIC_IS_DEMO="false"
1616
# DATABASE
1717
DATABASE_URL="postgres://${DOCKER_DATABASE_USERNAME}:${DOCKER_DATABASE_PASSWORD}@localhost:${DOCKER_DATABASE_PORT}/${DOCKER_DATABASE_NAME}"
1818

19+
# GITHUB
20+
GITHUB_CLIENT_ID="REPLACE ME"
21+
GITHUB_CLIENT_SECRET="REPLACE ME"
22+
23+
# GOOGLE
24+
GOOGLE_CLIENT_ID="REPLACE ME"
25+
GOOGLE_CLIENT_SECRET="REPLACE ME"
26+
27+
# DISCORD
28+
DISCORD_CLIENT_ID="REPLACE ME"
29+
DISCORD_CLIENT_SECRET="REPLACE ME"
30+
1931
# EMAILS
2032
EMAIL_SERVER="smtp://username:[email protected]:1025"
2133
EMAIL_FROM="Start UI <[email protected]>"

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
"@trpc/client": "10.45.2",
6565
"@trpc/react-query": "10.45.2",
6666
"@trpc/server": "10.45.2",
67+
"arctic": "1.9.2",
6768
"bcrypt": "5.1.1",
6869
"chakra-react-select": "4.9.1",
6970
"colorette": "2.0.20",

pnpm-lock.yaml

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

prisma/schema/auth.prisma

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,12 @@
1+
model OAuthAccount {
2+
provider String
3+
providerUserId String
4+
userId String
5+
user User @relation(references: [id], fields: [userId], onDelete: Cascade)
6+
7+
@@id([provider, providerUserId])
8+
}
9+
110
model Session {
211
id String @id
312
userId String

prisma/schema/user.prisma

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,17 @@ enum UserRole {
1313
}
1414

1515
model User {
16-
id String @id @default(cuid())
17-
createdAt DateTime @default(now())
18-
updatedAt DateTime @updatedAt
16+
id String @id @default(cuid())
17+
createdAt DateTime @default(now())
18+
updatedAt DateTime @updatedAt
1919
name String?
20-
email String? @unique
21-
isEmailVerified Boolean @default(false)
22-
accountStatus AccountStatus @default(NOT_VERIFIED)
20+
email String? @unique
21+
isEmailVerified Boolean @default(false)
22+
accountStatus AccountStatus @default(NOT_VERIFIED)
2323
image String?
24-
authorizations UserRole[] @default([APP])
25-
language String @default("en")
24+
authorizations UserRole[] @default([APP])
25+
language String @default("en")
2626
lastLoginAt DateTime?
2727
session Session[]
28+
oauth OAuthAccount[]
2829
}

src/app/oauth/[provider]/page.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
'use client';
2+
3+
import { Suspense } from 'react';
4+
5+
import PageOAuthCallback from '@/features/auth/PageOAuthCallback';
6+
7+
export default function Page() {
8+
return (
9+
<Suspense>
10+
<PageOAuthCallback />
11+
</Suspense>
12+
);
13+
}

src/env.mjs

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,6 @@
33
import { createEnv } from '@t3-oss/env-nextjs';
44
import { z } from 'zod';
55

6-
const zNodeEnv = () =>
7-
z.enum(['development', 'test', 'production']).default('development');
8-
96
export const env = createEnv({
107
/**
118
* Specify your server-side environment variables schema here. This way you can ensure the app
@@ -15,6 +12,15 @@ export const env = createEnv({
1512
DATABASE_URL: z.string().url(),
1613
NODE_ENV: zNodeEnv(),
1714

15+
GITHUB_CLIENT_ID: zOptionalWithReplaceMe(),
16+
GITHUB_CLIENT_SECRET: zOptionalWithReplaceMe(),
17+
18+
GOOGLE_CLIENT_ID: zOptionalWithReplaceMe(),
19+
GOOGLE_CLIENT_SECRET: zOptionalWithReplaceMe(),
20+
21+
DISCORD_CLIENT_ID: zOptionalWithReplaceMe(),
22+
DISCORD_CLIENT_SECRET: zOptionalWithReplaceMe(),
23+
1824
EMAIL_SERVER: z.string().url(),
1925
EMAIL_FROM: z.string(),
2026
LOGGER_LEVEL: z
@@ -77,6 +83,15 @@ export const env = createEnv({
7783
LOGGER_LEVEL: process.env.LOGGER_LEVEL,
7884
LOGGER_PRETTY: process.env.LOGGER_PRETTY,
7985

86+
GITHUB_CLIENT_ID: process.env.GITHUB_CLIENT_ID,
87+
GITHUB_CLIENT_SECRET: process.env.GITHUB_CLIENT_SECRET,
88+
89+
GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
90+
GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET,
91+
92+
DISCORD_CLIENT_ID: process.env.DISCORD_CLIENT_ID,
93+
DISCORD_CLIENT_SECRET: process.env.DISCORD_CLIENT_SECRET,
94+
8095
NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_VERCEL_URL
8196
? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`
8297
: process.env.NEXT_PUBLIC_BASE_URL,
@@ -92,3 +107,22 @@ export const env = createEnv({
92107
*/
93108
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
94109
});
110+
111+
function zNodeEnv() {
112+
return z.enum(['development', 'test', 'production']).default('development');
113+
}
114+
115+
function zOptionalWithReplaceMe() {
116+
return z
117+
.string()
118+
.optional()
119+
.refine(
120+
(value) =>
121+
// Check in prodution if the value is not REPLACE ME
122+
process.env.NODE_ENV !== 'production' || value !== 'REPLACE ME',
123+
{
124+
message: 'Update the value "REPLACE ME" or remove the variable',
125+
}
126+
)
127+
.transform((value) => (value === 'REPLACE ME' ? undefined : value));
128+
}

src/features/auth/OAuthLogin.tsx

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import {
2+
Button,
3+
ButtonProps,
4+
Divider,
5+
Flex,
6+
SimpleGrid,
7+
Text,
8+
} from '@chakra-ui/react';
9+
import { useRouter } from 'next/navigation';
10+
import { useTranslation } from 'react-i18next';
11+
12+
import { Icon } from '@/components/Icons';
13+
import { toastCustom } from '@/components/Toast';
14+
import {
15+
OAUTH_PROVIDERS,
16+
OAUTH_PROVIDERS_ENABLED_ARRAY,
17+
OAuthProvider,
18+
} from '@/features/auth/oauth-config';
19+
import { trpc } from '@/lib/trpc/client';
20+
21+
export const OAuthLoginButton = ({
22+
provider,
23+
...rest
24+
}: {
25+
provider: OAuthProvider;
26+
} & ButtonProps) => {
27+
const { t } = useTranslation(['auth']);
28+
const router = useRouter();
29+
const loginWith = trpc.oauth.createAuthorizationUrl.useMutation({
30+
onSuccess: (data) => {
31+
router.push(data.url);
32+
},
33+
onError: (error) => {
34+
toastCustom({
35+
status: 'error',
36+
title: t('auth:login.feedbacks.oAuthError.title', {
37+
provider: OAUTH_PROVIDERS[provider].label,
38+
}),
39+
description: error.message,
40+
});
41+
},
42+
});
43+
44+
return (
45+
<Button
46+
onClick={() => loginWith.mutate({ provider: provider })}
47+
isLoading={loginWith.isLoading || loginWith.isSuccess}
48+
leftIcon={<Icon icon={OAUTH_PROVIDERS[provider].icon} />}
49+
{...rest}
50+
>
51+
{OAUTH_PROVIDERS[provider].label}
52+
</Button>
53+
);
54+
};
55+
56+
export const OAuthLoginButtonsGrid = () => {
57+
if (!OAUTH_PROVIDERS_ENABLED_ARRAY.length) return null;
58+
return (
59+
<SimpleGrid columns={2} gap={3}>
60+
{OAUTH_PROVIDERS_ENABLED_ARRAY.map(({ provider }) => {
61+
return (
62+
<OAuthLoginButton
63+
key={provider}
64+
provider={provider}
65+
_first={{
66+
gridColumn:
67+
OAUTH_PROVIDERS_ENABLED_ARRAY.length % 2 !== 0
68+
? 'span 2'
69+
: undefined,
70+
}}
71+
/>
72+
);
73+
})}
74+
</SimpleGrid>
75+
);
76+
};
77+
78+
export const OAuthLoginDivider = () => {
79+
const { t } = useTranslation(['common']);
80+
if (!OAUTH_PROVIDERS_ENABLED_ARRAY.length) return null;
81+
return (
82+
<Flex alignItems="center" gap={2}>
83+
<Divider flex={1} />
84+
<Text fontSize="xs" color="text-dimmed" textTransform="uppercase">
85+
{t('common:or')}
86+
</Text>
87+
<Divider flex={1} />
88+
</Flex>
89+
);
90+
};

src/features/auth/PageLogin.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ import { useRouter } from 'next/navigation';
66
import { useTranslation } from 'react-i18next';
77

88
import { LoginForm } from '@/features/auth/LoginForm';
9+
import {
10+
OAuthLoginButtonsGrid,
11+
OAuthLoginDivider,
12+
} from '@/features/auth/OAuthLogin';
913
import { ROUTES_AUTH } from '@/features/auth/routes';
1014
import type { RouterInputs, RouterOutputs } from '@/lib/trpc/types';
1115

@@ -51,6 +55,10 @@ export default function PageLogin() {
5155
</Box>
5256
</Button>
5357
</Stack>
58+
59+
<OAuthLoginButtonsGrid />
60+
<OAuthLoginDivider />
61+
5462
<LoginForm onSuccess={handleOnSuccess} />
5563
</Stack>
5664
);
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import React, { useEffect, useRef } from 'react';
2+
3+
import {
4+
notFound,
5+
useParams,
6+
useRouter,
7+
useSearchParams,
8+
} from 'next/navigation';
9+
import { useTranslation } from 'react-i18next';
10+
import { z } from 'zod';
11+
12+
import { LoaderFull } from '@/components/LoaderFull';
13+
import { toastCustom } from '@/components/Toast';
14+
import { ROUTES_ADMIN } from '@/features/admin/routes';
15+
import { ROUTES_APP } from '@/features/app/routes';
16+
import { zOAuthProvider } from '@/features/auth/oauth-config';
17+
import { ROUTES_AUTH } from '@/features/auth/routes';
18+
import { trpc } from '@/lib/trpc/client';
19+
20+
export default function PageOAuthCallback() {
21+
const { i18n, t } = useTranslation(['auth']);
22+
const router = useRouter();
23+
const isTriggeredRef = useRef(false);
24+
const params = z
25+
.object({ provider: zOAuthProvider() })
26+
.safeParse(useParams());
27+
const searchParams = z
28+
.object({ code: z.string(), state: z.string() })
29+
.safeParse({
30+
code: useSearchParams().get('code'),
31+
state: useSearchParams().get('state'),
32+
});
33+
const validateLogin = trpc.oauth.validateLogin.useMutation({
34+
onSuccess: (data) => {
35+
if (data.account.authorizations.includes('ADMIN')) {
36+
router.replace(ROUTES_ADMIN.root());
37+
return;
38+
}
39+
router.replace(ROUTES_APP.root());
40+
},
41+
onError: () => {
42+
toastCustom({
43+
status: 'error',
44+
title: t('auth:login.feedbacks.loginError.title'),
45+
});
46+
router.replace(ROUTES_AUTH.login());
47+
},
48+
});
49+
50+
useEffect(() => {
51+
const trigger = () => {
52+
if (isTriggeredRef.current) return;
53+
isTriggeredRef.current = true;
54+
55+
if (!(params.success && searchParams.success)) {
56+
notFound();
57+
}
58+
59+
validateLogin.mutate({
60+
provider: params.data.provider,
61+
code: searchParams.data.code,
62+
state: searchParams.data.state,
63+
language: i18n.language,
64+
});
65+
};
66+
trigger();
67+
}, [validateLogin, params, searchParams, i18n]);
68+
69+
return <LoaderFull />;
70+
}

0 commit comments

Comments
 (0)