diff --git a/src/app/api/auth/patreon/[code]/route.ts b/src/app/api/auth/patreon/[code]/route.ts new file mode 100644 index 000000000..9230c4b55 --- /dev/null +++ b/src/app/api/auth/patreon/[code]/route.ts @@ -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); +} diff --git a/src/app/api/auth/patreon/refresh/[token]/route.ts b/src/app/api/auth/patreon/refresh/[token]/route.ts new file mode 100644 index 000000000..a34e8e376 --- /dev/null +++ b/src/app/api/auth/patreon/refresh/[token]/route.ts @@ -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); +} diff --git a/src/app/auth/[provider]/page.tsx b/src/app/auth/[provider]/page.tsx new file mode 100644 index 000000000..43323a52b --- /dev/null +++ b/src/app/auth/[provider]/page.tsx @@ -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 ( + + + Verifying you... + You'll be on your way shortly. + + + ); +} diff --git a/src/app/callback/[provider]/page.tsx b/src/app/callback/[provider]/page.tsx deleted file mode 100644 index 1c21aac6c..000000000 --- a/src/app/callback/[provider]/page.tsx +++ /dev/null @@ -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 ( - - - Verifying you... - You'll be on your way shortly. - - - ); -} diff --git a/src/app/components/Checks.tsx b/src/app/components/Checks.tsx index a935d348d..719907fcb 100644 --- a/src/app/components/Checks.tsx +++ b/src/app/components/Checks.tsx @@ -10,17 +10,24 @@ import { } from '@radix-ui/themes'; import { usePathname } from 'next/navigation'; import { useEffect, useState } from 'react'; -import { extendAuth } from '../../core/blitz/extendAuth'; -import { logout } from '../../core/blitz/logout'; +import { extendAuthPatreon } from '../../core/blitz/extendAuthPatreon'; +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 { isLocalhost } from '../../core/blitzkit/isLocalhost'; import { CURRENT_POLICIES_AGREEMENT_INDEX, useApp } from '../../stores/app'; const DEV_BUILD_AGREEMENT_COOLDOWN = 8 * 24 * 60 * 60 * 1000; +/** + * Wargaming: 14 (refresh: 7) + * Patreon: 31 (refresh: 15) + */ + export function Checks() { const [showDevBuildAlert, setShowDevBuildAlert] = useState(false); - const login = useApp((state) => state.login); + const logins = useApp((state) => state.logins); const policiesAgreementIndex = useApp( (state) => state.policiesAgreementIndex, ); @@ -43,15 +50,26 @@ export function Checks() { }, [policiesAgreementIndex, isLegal]); 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; - const expiresInDays = expiresIn / 60 / 60 / 24; + if (expiresInDays < 0) { + logoutWargaming(); + } else if (expiresInDays < 7) { + extendAuthWargaming(); + } + } - if (expiresInDays < 0) { - logout(); - } else if (expiresInDays < 7) { - extendAuth(); + if (logins.patreon) { + const expiresIn = logins.patreon.expires - Date.now(); + const expiresInDays = expiresIn / 1000 / 60 / 60 / 24; + + if (expiresInDays < 0) { + logoutPatreon(); + } else if (expiresInDays < 15) { + extendAuthPatreon(); + } } }); diff --git a/src/components/Navbar/components/LoggedIn.tsx b/src/components/Navbar/components/LoggedIn.tsx deleted file mode 100644 index bafdbe7ed..000000000 --- a/src/components/Navbar/components/LoggedIn.tsx +++ /dev/null @@ -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 ( - - Hello, {accountInfo.nickname} - - - - - - ); -} diff --git a/src/components/Navbar/index.tsx b/src/components/Navbar/index.tsx index 34c259ab4..26ac4c298 100644 --- a/src/components/Navbar/index.tsx +++ b/src/components/Navbar/index.tsx @@ -5,28 +5,16 @@ import { HamburgerMenuIcon, PersonIcon, } from '@radix-ui/react-icons'; -import { - Box, - Button, - Dialog, - Flex, - Heading, - IconButton, - Popover, - Spinner, - Text, -} from '@radix-ui/themes'; +import { Box, Button, Flex, IconButton, Popover, Text } from '@radix-ui/themes'; import { usePathname } from 'next/navigation'; -import { Suspense, useState } from 'react'; -import { REGIONS, UNLOCALIZED_REGION_NAMES } from '../../constants/regions'; +import { useState } from 'react'; import { TOOLS } from '../../constants/tools'; -import { authURL } from '../../core/blitz/authURL'; import { BlitzkitWide } from '../../icons/BlitzkitWide'; import { PatreonIcon } from '../../icons/Patreon'; +import { WargamingIcon } from '../../icons/Wargaming'; import { theme } from '../../stitches.config'; import { useApp } from '../../stores/app'; import { Link } from '../Link'; -import { LoggedIn } from './components/LoggedIn'; import * as styles from './index.css'; export const NAVBAR_HEIGHT = 64; @@ -34,7 +22,7 @@ export const NAVBAR_HEIGHT = 64; export default function Navbar() { const [showHamburgerMenu, setShowHamburgerMenu] = useState(false); const pathname = usePathname(); - const login = useApp((state) => state.login!); + const logins = useApp((state) => state.logins); return ( - setShowHamburgerMenu(false)}> + setShowHamburgerMenu(false)} + color="gray" + highContrast + > - - - + - {login && ( - - - - - - + + + + - - - }> - - - - - - )} + + + Log in with... - {!login && false && ( - - - - - - - - Choose your region - - - {REGIONS.map((region) => ( - setShowHamburgerMenu(false)} - key={region} - href={authURL( - region, - typeof window === 'undefined' - ? undefined - : location.href, - )} - > - {UNLOCALIZED_REGION_NAMES[region]} - - ))} - - - - - )} + + + + + + + diff --git a/src/core/blitz/authURL.ts b/src/core/blitz/authURL.ts deleted file mode 100644 index b6853e9dd..000000000 --- a/src/core/blitz/authURL.ts +++ /dev/null @@ -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}` : ''}`, - )}`; -} diff --git a/src/core/blitz/extendAuthPatreon.ts b/src/core/blitz/extendAuthPatreon.ts new file mode 100644 index 000000000..64bdea9c9 --- /dev/null +++ b/src/core/blitz/extendAuthPatreon.ts @@ -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, + }; + }); +} diff --git a/src/core/blitz/extendAuth.ts b/src/core/blitz/extendAuthWargaming.ts similarity index 93% rename from src/core/blitz/extendAuth.ts rename to src/core/blitz/extendAuthWargaming.ts index 578e7da38..fe507db07 100644 --- a/src/core/blitz/extendAuth.ts +++ b/src/core/blitz/extendAuthWargaming.ts @@ -10,10 +10,10 @@ interface Extension { }; } -export async function extendAuth() { - const { login } = useApp.getState(); +export async function extendAuthWargaming() { + const { wargaming } = useApp.getState().logins; - if (!login) return; + if (!wargaming) return; // error handling? what's that? /* @@ -42,18 +42,18 @@ export async function extendAuth() { const response = await fetch( `https://api.worldoftanks.${idToRegion( - login.id, + wargaming.id, )}/wot/auth/prolongate/?application_id=${WARGAMING_APPLICATION_ID}&access_token=${ - login.token + wargaming.token }`, ); const json = (await response.json()) as Extension; mutateApp((draft) => { - draft.login = { - id: login.id, + draft.logins.wargaming = { + id: wargaming.id, token: json.data.access_token, - expiresAt: json.data.expires_at, + expires: json.data.expires_at * 1000, }; }); } diff --git a/src/core/blitz/logout.ts b/src/core/blitz/logoutPatreon.ts similarity index 52% rename from src/core/blitz/logout.ts rename to src/core/blitz/logoutPatreon.ts index 290aa4774..fe7152882 100644 --- a/src/core/blitz/logout.ts +++ b/src/core/blitz/logoutPatreon.ts @@ -1,7 +1,7 @@ import { mutateApp } from '../../stores/app'; -export function logout() { +export function logoutPatreon() { mutateApp((draft) => { - draft.login = undefined; + draft.logins.patreon = undefined; }); } diff --git a/src/core/blitz/logoutWargaming.ts b/src/core/blitz/logoutWargaming.ts new file mode 100644 index 000000000..f96421385 --- /dev/null +++ b/src/core/blitz/logoutWargaming.ts @@ -0,0 +1,7 @@ +import { mutateApp } from '../../stores/app'; + +export function logoutWargaming() { + mutateApp((draft) => { + draft.logins.wargaming = undefined; + }); +} diff --git a/src/icons/Wargaming.tsx b/src/icons/Wargaming.tsx new file mode 100644 index 000000000..458b7951a --- /dev/null +++ b/src/icons/Wargaming.tsx @@ -0,0 +1,28 @@ +import { ComponentProps } from 'react'; + +export function WargamingIcon(props: ComponentProps<'svg'>) { + return ( + + + + + + + + + + + ); +} diff --git a/src/stores/app.ts b/src/stores/app.ts index 0931749f3..f0355572b 100644 --- a/src/stores/app.ts +++ b/src/stores/app.ts @@ -3,16 +3,25 @@ import { merge } from 'lodash'; import { create } from 'zustand'; import { persist } from 'zustand/middleware'; -interface Login { +interface WargamingLogin { id: number; token: string; - expiresAt: number; + expires: number; +} + +interface PatreonLogin { + token: string; + refreshToken: string; + expires: number; } export interface App { devBuildAgreementTime: number; developerMode: boolean; - login?: Login; + logins: { + wargaming?: WargamingLogin; + patreon?: PatreonLogin; + }; policiesAgreementIndex: number; } @@ -24,6 +33,7 @@ export const useApp = create()( devBuildAgreementTime: 0, developerMode: false, policiesAgreementIndex: -1, + logins: {}, }), { name: 'app', merge: (a, b) => merge(b, a) }, ),