feat: patreon login

This commit is contained in:
TrèsAbhi committed 2024-06-13 14:52:43 -05:00
1 parent 03aa690247
commit a7e89c2147
14 files changed
+260 -191

No files matched your search

+17
View File
@@ -0,0 +1,17 @@
import { NextResponse } from 'next/server';
export async function GET(
request: Request,
{ params }: { params: { code: string } },
) {
const response = await fetch(
`https://www.patreon.com/api/oauth2/token?code=${params.code}&grant_type=authorization_code&client_id=${process.env.NEXT_PUBLIC_PATREON_CLIENT_ID}&client_secret=${process.env.PATREON_CLIENT_SECRET}&redirect_uri=${process.env.NEXT_PUBLIC_PATREON_REDIRECT_URI}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
},
);
const json = await response.json();
return NextResponse.json(json);
}
@@ -0,0 +1,14 @@
import { NextResponse } from 'next/server';
export async function GET(
request: Request,
{ params }: { params: { token: string } },
) {
const response = await fetch(
`https://www.patreon.com/api/oauth2/token?grant_type=refresh_token&refresh_token=${params.token}&client_id=${process.env.NEXT_PUBLIC_PATREON_CLIENT_ID}&client_secret=${process.env.PATREON_CLIENT_SECRET}`,
{ method: 'POST' },
);
const json = await response.json();
return NextResponse.json(json);
}
+91
View File
@@ -0,0 +1,91 @@
'use client';
import { Flex, Heading, Text } from '@radix-ui/themes';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
import PageWrapper from '../../../components/PageWrapper';
import { mutateApp } from '../../../stores/app';
export interface PatreonAuthResponse {
access_token: string;
expires_in: number;
token_type: 'Bearer';
scope: string;
refresh_token: string;
version: string;
}
export default function Page({
params,
searchParams,
}: {
params: { provider: string };
searchParams: { [key: string]: string };
}) {
const router = useRouter();
useEffect(() => {
(async () => {
switch (params.provider) {
case 'wargaming': {
if (
!searchParams.expires_at ||
!searchParams.access_token ||
!searchParams.account_id
) {
break;
}
mutateApp((draft) => {
draft.logins.wargaming = {
id: Number(searchParams.account_id),
token: searchParams.access_token,
expires: Number(searchParams.expires_at) * 1000,
};
});
break;
}
case 'patreon': {
if (!searchParams.code) break;
const data = (await fetch(
`/api/auth/patreon/${searchParams.code}`,
).then((response) => response.json())) as PatreonAuthResponse;
mutateApp((draft) => {
draft.logins.patreon = {
token: data.access_token,
refreshToken: data.refresh_token,
expires: Date.now() + data.expires_in * 1000,
};
});
break;
}
default: {
console.error(`Unknown provider: ${params.provider}`);
}
}
router.push(searchParams.return ?? '/');
})();
});
return (
<PageWrapper>
<Flex
style={{ flex: 1 }}
align="center"
justify="center"
direction="column"
gap="2"
>
<Heading>Verifying you...</Heading>
<Text color="gray">You'll be on your way shortly.</Text>
</Flex>
</PageWrapper>
);
}
-62
View File
@@ -1,62 +0,0 @@
'use client';
import { Flex, Heading, Text } from '@radix-ui/themes';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
import PageWrapper from '../../../components/PageWrapper';
import { mutateApp } from '../../../stores/app';
export default function Page({
params,
searchParams,
}: {
params: { provider: string };
searchParams: { [key: string]: string };
}) {
const router = useRouter();
useEffect(() => {
switch (params.provider) {
case 'wargaming': {
if (
!searchParams.expires_at ||
!searchParams.access_token ||
!searchParams.account_id
) {
break;
}
mutateApp((draft) => {
draft.login = {
expiresAt: Number(searchParams.expires_at),
id: Number(searchParams.account_id),
token: searchParams.access_token,
};
});
break;
}
default: {
console.error(`Unknown provider: ${params.provider}`);
}
}
router.push(searchParams.return ? searchParams.return : '/');
});
return (
<PageWrapper>
<Flex
style={{ flex: 1 }}
align="center"
justify="center"
direction="column"
gap="2"
>
<Heading>Verifying you...</Heading>
<Text color="gray">You'll be on your way shortly.</Text>
</Flex>
</PageWrapper>
);
}
+28 -10
View File
@@ -10,17 +10,24 @@ import {
} from '@radix-ui/themes'; } from '@radix-ui/themes';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { extendAuth } from '../../core/blitz/extendAuth'; import { extendAuthPatreon } from '../../core/blitz/extendAuthPatreon';
import { logout } from '../../core/blitz/logout'; import { extendAuthWargaming } from '../../core/blitz/extendAuthWargaming';
import { logoutPatreon } from '../../core/blitz/logoutPatreon';
import { logoutWargaming } from '../../core/blitz/logoutWargaming';
import isDev from '../../core/blitzkit/isDev'; import isDev from '../../core/blitzkit/isDev';
import { isLocalhost } from '../../core/blitzkit/isLocalhost'; import { isLocalhost } from '../../core/blitzkit/isLocalhost';
import { CURRENT_POLICIES_AGREEMENT_INDEX, useApp } from '../../stores/app'; import { CURRENT_POLICIES_AGREEMENT_INDEX, useApp } from '../../stores/app';
const DEV_BUILD_AGREEMENT_COOLDOWN = 8 * 24 * 60 * 60 * 1000; const DEV_BUILD_AGREEMENT_COOLDOWN = 8 * 24 * 60 * 60 * 1000;
/**
* Wargaming: 14 (refresh: 7)
* Patreon: 31 (refresh: 15)
*/
export function Checks() { export function Checks() {
const [showDevBuildAlert, setShowDevBuildAlert] = useState(false); const [showDevBuildAlert, setShowDevBuildAlert] = useState(false);
const login = useApp((state) => state.login); const logins = useApp((state) => state.logins);
const policiesAgreementIndex = useApp( const policiesAgreementIndex = useApp(
(state) => state.policiesAgreementIndex, (state) => state.policiesAgreementIndex,
); );
@@ -43,15 +50,26 @@ export function Checks() {
}, [policiesAgreementIndex, isLegal]); }, [policiesAgreementIndex, isLegal]);
useEffect(() => { useEffect(() => {
if (!login) return; if (logins.wargaming) {
const expiresIn = logins.wargaming.expires - Date.now();
const expiresInDays = expiresIn / 1000 / 60 / 60 / 24;
const expiresIn = login.expiresAt - Date.now() / 1000; if (expiresInDays < 0) {
const expiresInDays = expiresIn / 60 / 60 / 24; logoutWargaming();
} else if (expiresInDays < 7) {
extendAuthWargaming();
}
}
if (expiresInDays < 0) { if (logins.patreon) {
logout(); const expiresIn = logins.patreon.expires - Date.now();
} else if (expiresInDays < 7) { const expiresInDays = expiresIn / 1000 / 60 / 60 / 24;
extendAuth();
if (expiresInDays < 0) {
logoutPatreon();
} else if (expiresInDays < 15) {
extendAuthPatreon();
}
} }
}); });
@@ -1,26 +0,0 @@
import { ExitIcon } from '@radix-ui/react-icons';
import { Flex, IconButton, Text } from '@radix-ui/themes';
import { use } from 'react';
import { getAccountInfo } from '../../../core/blitz/getAccountInfo';
import { idToRegion } from '../../../core/blitz/idToRegion';
import { logout } from '../../../core/blitz/logout';
import { useApp } from '../../../stores/app';
export function LoggedIn() {
const login = useApp((state) => state.login!);
const accountInfo = use(
getAccountInfo(idToRegion(login.id), login.id, [], {
access_token: login.token,
}),
);
return (
<Flex gap="3" align="center">
<Text>Hello, {accountInfo.nickname}</Text>
<IconButton variant="ghost" color="red" onClick={logout}>
<ExitIcon />
</IconButton>
</Flex>
);
}
+32 -70
View File
@@ -5,28 +5,16 @@ import {
HamburgerMenuIcon, HamburgerMenuIcon,
PersonIcon, PersonIcon,
} from '@radix-ui/react-icons'; } from '@radix-ui/react-icons';
import { import { Box, Button, Flex, IconButton, Popover, Text } from '@radix-ui/themes';
Box,
Button,
Dialog,
Flex,
Heading,
IconButton,
Popover,
Spinner,
Text,
} from '@radix-ui/themes';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { Suspense, useState } from 'react'; import { useState } from 'react';
import { REGIONS, UNLOCALIZED_REGION_NAMES } from '../../constants/regions';
import { TOOLS } from '../../constants/tools'; import { TOOLS } from '../../constants/tools';
import { authURL } from '../../core/blitz/authURL';
import { BlitzkitWide } from '../../icons/BlitzkitWide'; import { BlitzkitWide } from '../../icons/BlitzkitWide';
import { PatreonIcon } from '../../icons/Patreon'; import { PatreonIcon } from '../../icons/Patreon';
import { WargamingIcon } from '../../icons/Wargaming';
import { theme } from '../../stitches.config'; import { theme } from '../../stitches.config';
import { useApp } from '../../stores/app'; import { useApp } from '../../stores/app';
import { Link } from '../Link'; import { Link } from '../Link';
import { LoggedIn } from './components/LoggedIn';
import * as styles from './index.css'; import * as styles from './index.css';
export const NAVBAR_HEIGHT = 64; export const NAVBAR_HEIGHT = 64;
@@ -34,7 +22,7 @@ export const NAVBAR_HEIGHT = 64;
export default function Navbar() { export default function Navbar() {
const [showHamburgerMenu, setShowHamburgerMenu] = useState(false); const [showHamburgerMenu, setShowHamburgerMenu] = useState(false);
const pathname = usePathname(); const pathname = usePathname();
const login = useApp((state) => state.login!); const logins = useApp((state) => state.logins);
return ( return (
<Box <Box
@@ -120,66 +108,40 @@ export default function Navbar() {
<PatreonIcon width={14} height={14} /> <PatreonIcon width={14} height={14} />
</Link> </Link>
<Link href="/settings" onClick={() => setShowHamburgerMenu(false)}> <Link
href="/settings"
onClick={() => setShowHamburgerMenu(false)}
color="gray"
highContrast
>
<Flex style={{ width: '100%', height: '100%' }} justify="center"> <Flex style={{ width: '100%', height: '100%' }} justify="center">
<IconButton size="4" variant="ghost" color="gray"> <GearIcon />
<GearIcon />
</IconButton>
</Flex> </Flex>
</Link> </Link>
{login && ( <Popover.Root>
<Popover.Root> <Popover.Trigger>
<Popover.Trigger> <PersonIcon style={{ cursor: 'pointer' }} />
<IconButton size="4" variant="ghost" color="gray"> </Popover.Trigger>
<PersonIcon />
</IconButton>
</Popover.Trigger>
<Popover.Content align="center"> <Popover.Content align="end" width="320px">
<Flex direction="column" gap="2"> <Flex direction="column" gap="2">
<Suspense fallback={<Spinner />}> <Text align="center">Log in with...</Text>
<LoggedIn />
</Suspense>
</Flex>
</Popover.Content>
</Popover.Root>
)}
{!login && false && ( <Link
<Dialog.Root> style={{ width: '100%' }}
<Dialog.Trigger> href={`https://www.patreon.com/oauth2/authorize?response_type=code&client_id=${process.env.NEXT_PUBLIC_PATREON_CLIENT_ID}&redirect_uri=${process.env.NEXT_PUBLIC_PATREON_REDIRECT_URI}`}
<Button variant="ghost">Log in</Button> >
</Dialog.Trigger> <Button style={{ width: '100%' }} color="tomato">
<PatreonIcon width={15} height={15} /> Patreon
<Dialog.Content </Button>
style={{ </Link>
maxWidth: 360, <Button color="red">
}} <WargamingIcon width={15} height={15} /> Wargaming
> </Button>
<Flex direction="column" gap="2" align="center"> </Flex>
<Heading size="3">Choose your region</Heading> </Popover.Content>
</Popover.Root>
<Flex gap="4" align="center" justify="center" wrap="wrap">
{REGIONS.map((region) => (
<Link
onClick={() => setShowHamburgerMenu(false)}
key={region}
href={authURL(
region,
typeof window === 'undefined'
? undefined
: location.href,
)}
>
{UNLOCALIZED_REGION_NAMES[region]}
</Link>
))}
</Flex>
</Flex>
</Dialog.Content>
</Dialog.Root>
)}
</Flex> </Flex>
</Flex> </Flex>
-10
View File
@@ -1,10 +0,0 @@
import { Region } from '../../constants/regions';
import { WARGAMING_APPLICATION_ID } from '../../constants/wargamingApplicationID';
export function authURL(region: Region, callback?: string) {
if (typeof window === 'undefined') throw new Error('window is undefined');
return `https://api.worldoftanks.${region}/wot/auth/login/?application_id=${WARGAMING_APPLICATION_ID}&redirect_uri=${encodeURIComponent(
`${window.location.origin}/callback/wargaming/${callback ? `?return=${location.href}` : ''}`,
)}`;
}
+20
View File
@@ -0,0 +1,20 @@
import { PatreonAuthResponse } from '../../app/auth/[provider]/page';
import { mutateApp, useApp } from '../../stores/app';
export async function extendAuthPatreon() {
const { patreon } = useApp.getState().logins;
if (!patreon) return;
const data = (await fetch(
`/api/auth/patreon/refresh/${patreon.refreshToken}`,
).then((response) => response.json())) as PatreonAuthResponse;
mutateApp((draft) => {
draft.logins.patreon = {
token: data.access_token,
refreshToken: data.refresh_token,
expires: Date.now() + data.expires_in * 1000,
};
});
}
@@ -10,10 +10,10 @@ interface Extension {
}; };
} }
export async function extendAuth() { export async function extendAuthWargaming() {
const { login } = useApp.getState(); const { wargaming } = useApp.getState().logins;
if (!login) return; if (!wargaming) return;
// error handling? what's that? // error handling? what's that?
/* /*
@@ -42,18 +42,18 @@ export async function extendAuth() {
const response = await fetch( const response = await fetch(
`https://api.worldoftanks.${idToRegion( `https://api.worldoftanks.${idToRegion(
login.id, wargaming.id,
)}/wot/auth/prolongate/?application_id=${WARGAMING_APPLICATION_ID}&access_token=${ )}/wot/auth/prolongate/?application_id=${WARGAMING_APPLICATION_ID}&access_token=${
login.token wargaming.token
}`, }`,
); );
const json = (await response.json()) as Extension; const json = (await response.json()) as Extension;
mutateApp((draft) => { mutateApp((draft) => {
draft.login = { draft.logins.wargaming = {
id: login.id, id: wargaming.id,
token: json.data.access_token, token: json.data.access_token,
expiresAt: json.data.expires_at, expires: json.data.expires_at * 1000,
}; };
}); });
} }
@@ -1,7 +1,7 @@
import { mutateApp } from '../../stores/app'; import { mutateApp } from '../../stores/app';
export function logout() { export function logoutPatreon() {
mutateApp((draft) => { mutateApp((draft) => {
draft.login = undefined; draft.logins.patreon = undefined;
}); });
} }
+7
View File
@@ -0,0 +1,7 @@
import { mutateApp } from '../../stores/app';
export function logoutWargaming() {
mutateApp((draft) => {
draft.logins.wargaming = undefined;
});
}
+28
View File
@@ -0,0 +1,28 @@
import { ComponentProps } from 'react';
export function WargamingIcon(props: ComponentProps<'svg'>) {
return (
<svg
width="15"
height="15"
viewBox="0 0 15 15"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<g clip-path="url(#clip0_574_231)">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M7.46409 0.156338C8.91652 0.149247 10.3384 0.57301 11.55 1.37404C12.7616 2.17507 13.7085 3.31739 14.2709 4.65654C14.8332 5.9957 14.9859 7.47154 14.7095 8.89745C14.4331 10.3234 13.7401 11.6353 12.7181 12.6673C11.6961 13.6994 10.391 14.4052 8.96789 14.6955C7.54475 14.9859 6.06748 14.8477 4.72288 14.2984C3.37829 13.7492 2.22676 12.8136 1.41391 11.6099C0.601062 10.4062 0.163403 8.98846 0.156278 7.53603V7.50009C0.150882 5.5586 0.916644 3.69442 2.28523 2.31733C3.65382 0.940243 5.51323 0.162958 7.45472 0.156338H7.46409ZM7.46409 1.56259C8.76962 1.56799 10.0372 2.00218 11.0719 2.79837C12.1065 3.59456 12.8509 4.70863 13.1906 5.96922C13.5302 7.2298 13.4463 8.56706 12.9517 9.77529C12.4571 10.9835 11.5792 11.9958 10.4532 12.6563C10.8305 11.3103 10.9857 9.91172 10.9125 8.51571C11.2938 8.62353 11.5094 8.68446 11.8922 8.8079C11.47 7.70829 10.9518 6.64795 10.3438 5.63915C9.79373 6.67527 9.10279 7.63018 8.29065 8.47665L9.51565 8.40009C8.46097 10.2938 6.97347 11.2126 5.08909 11.4798C4.6594 9.33759 4.98128 5.85478 7.38597 4.21728L7.62972 5.38603C8.09847 4.63134 9.02503 3.6329 9.71409 2.93759C8.57965 2.49502 7.34402 2.37866 6.1469 2.60165C6.34534 2.77196 6.59065 2.98759 6.78909 3.15634C4.07815 4.3079 2.58909 6.20009 1.6094 8.40009C1.48639 7.55586 1.54531 6.69509 1.78221 5.87549C2.0191 5.0559 2.4285 4.29643 2.98295 3.64802C3.5374 2.99961 4.22409 2.47724 4.99695 2.11595C5.76982 1.75465 6.611 1.56279 7.46409 1.55321V1.56259Z"
fill="currentColor"
/>
</g>
<defs>
<clipPath id="clip0_574_231">
<rect width="15" height="15" fill="currentColor" />
</clipPath>
</defs>
</svg>
);
}
+13 -3
View File
@@ -3,16 +3,25 @@ import { merge } from 'lodash';
import { create } from 'zustand'; import { create } from 'zustand';
import { persist } from 'zustand/middleware'; import { persist } from 'zustand/middleware';
interface Login { interface WargamingLogin {
id: number; id: number;
token: string; token: string;
expiresAt: number; expires: number;
}
interface PatreonLogin {
token: string;
refreshToken: string;
expires: number;
} }
export interface App { export interface App {
devBuildAgreementTime: number; devBuildAgreementTime: number;
developerMode: boolean; developerMode: boolean;
login?: Login; logins: {
wargaming?: WargamingLogin;
patreon?: PatreonLogin;
};
policiesAgreementIndex: number; policiesAgreementIndex: number;
} }
@@ -24,6 +33,7 @@ export const useApp = create<App>()(
devBuildAgreementTime: 0, devBuildAgreementTime: 0,
developerMode: false, developerMode: false,
policiesAgreementIndex: -1, policiesAgreementIndex: -1,
logins: {},
}), }),
{ name: 'app', merge: (a, b) => merge(b, a) }, { name: 'app', merge: (a, b) => merge(b, a) },
), ),