Skip to content

Commit

Permalink
.
Browse files Browse the repository at this point in the history
  • Loading branch information
exKAZUu committed Dec 30, 2023
1 parent f961f00 commit 1bbd0f6
Show file tree
Hide file tree
Showing 19 changed files with 1,081 additions and 14 deletions.
7 changes: 7 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,17 +34,24 @@
"@emotion/styled": "11.11.0",
"@prisma/client": "5.5.2",
"@willbooster/shared-lib-react": "3.0.0",
"cookie": "0.6.0",
"framer-motion": "10.16.4",
"next": "14.0.1",
"nextjs-cors": "2.2.0",
"pino": "8.16.0",
"pino-pretty": "10.2.3",
"pm2": "5.3.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-icons": "4.12.0",
"supertokens-auth-react": "0.36.1",
"supertokens-node": "16.6.8",
"supertokens-web-js": "0.8.0",
"zod": "3.22.4"
},
"devDependencies": {
"@chakra-ui/cli": "2.4.1",
"@types/cookie": "^0",
"@types/eslint": "8.44.6",
"@types/micromatch": "4.0.4",
"@types/node": "20.8.10",
Expand Down
26 changes: 26 additions & 0 deletions src/app/(home)/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
'use client';

import { Container, Spinner } from '@chakra-ui/react';
import React, { Suspense } from 'react';

import { DefaultFooter } from '../../components/organisms/DefaultFooter';
import { DefaultHeader } from '../../components/organisms/DefaultHeader';
import type { LayoutComponent } from '../../types';

const DefaultLayout: LayoutComponent = ({ children }) => {
return (
<>
<DefaultHeader />

<Suspense fallback={<Spinner left="50%" position="fixed" top="50%" transform="translate(-50%, -50%)" />}>
<Container pb={16} pt={8}>
{children}
</Container>
</Suspense>

<DefaultFooter />
</>
);
};

export default DefaultLayout;
4 changes: 2 additions & 2 deletions src/app/page.tsx → src/app/(home)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Button, Heading, Text, VStack } from '@chakra-ui/react';
import type { NextPage } from 'next';
import NextLink from 'next/link';

import { prisma } from '../infrastructures/prisma';
import { prisma } from '../../infrastructures/prisma';

const HomePage: NextPage = async () => {
const users = await prisma.user.findMany();
Expand All @@ -13,7 +13,7 @@ const HomePage: NextPage = async () => {
<Heading as="div" lineHeight="base" size="2xl" textAlign="center">
トレーシング力を鍛えよう
</Heading>
<Button as={NextLink} colorScheme="brand" href="/problems" size="lg">
<Button as={NextLink} colorScheme="brand" href="/courses" size="lg">
今すぐはじめる
</Button>
<ul>
Expand Down
38 changes: 38 additions & 0 deletions src/app/api/auth/[[...path]]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { getAppDirRequestHandler } from 'supertokens-node/nextjs';

import { ensureSuperTokensInit } from '../../../../infrastructures/supertokens/backendConfig';

ensureSuperTokensInit();

const handleCall = getAppDirRequestHandler(NextResponse);

export async function GET(request: NextRequest): Promise<Response> {
const res = await handleCall(request);
if (!res.headers.has('Cache-Control')) {
// This is needed for production deployments with Vercel
res.headers.set('Cache-Control', 'no-cache, no-store, max-age=0, must-revalidate');
}
return res;
}

export async function POST(request: NextRequest): Promise<Response> {
return handleCall(request);
}

export async function DELETE(request: NextRequest): Promise<Response> {
return handleCall(request);
}

export async function PUT(request: NextRequest): Promise<Response> {
return handleCall(request);
}

export async function PATCH(request: NextRequest): Promise<Response> {
return handleCall(request);
}

export async function HEAD(request: NextRequest): Promise<Response> {
return handleCall(request);
}
26 changes: 26 additions & 0 deletions src/app/auth/[[...path]]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
'use client';

import type { NextPage } from 'next';
import { useEffect, useState } from 'react';
import { redirectToAuth } from 'supertokens-auth-react';
import { EmailPasswordPreBuiltUI } from 'supertokens-auth-react/recipe/emailpassword/prebuiltui';
import SuperTokens from 'supertokens-auth-react/ui';

const AuthPage: NextPage = () => {
// if the user visits a page that is not handled by us (like /auth/random), then we redirect them back to the auth page.
const [loaded, setLoaded] = useState(false);

useEffect(() => {
if (SuperTokens.canHandleRoute([EmailPasswordPreBuiltUI])) {
setLoaded(true);
} else {
void redirectToAuth({ redirectBack: false });
}
}, []);

if (loaded) {
return SuperTokens.getRoutingComponent([EmailPasswordPreBuiltUI]);
}
};

export default AuthPage;
14 changes: 14 additions & 0 deletions src/app/courses/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { NextPage } from 'next';

import { HomePage } from '../../components/pages/homePage';

const CoursePage: NextPage = async () => {
return (
<main>
<h1>Courses</h1>
<HomePage />
</main>
);
};

export default CoursePage;
139 changes: 139 additions & 0 deletions src/app/sessionUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { serialize } from 'cookie';
import { cookies, headers } from 'next/headers';
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { PreParsedRequest, CollectingResponse } from 'supertokens-node/framework/custom';
import type { SessionContainer, VerifySessionOptions } from 'supertokens-node/recipe/session';
import Session from 'supertokens-node/recipe/session';
import type { HTTPMethod } from 'supertokens-node/types';

import { ensureSuperTokensInit } from '../infrastructures/supertokens/backendConfig';

ensureSuperTokensInit();

interface SSRSession {
session: SessionContainer | undefined;
hasToken: boolean;
/**
* This allows us to protect our routes based on the current session claims. For example
* this will be true if email verification is required but the user has not verified their
* email.
*/
hasInvalidClaims: boolean;
baseResponse: CollectingResponse;
nextResponse?: NextResponse;
}

export async function getSSRSession(req?: NextRequest, options?: VerifySessionOptions): Promise<SSRSession> {
const query = req === undefined ? {} : Object.fromEntries(new URL(req.url).searchParams.entries());
const parsedCookies: Record<string, string> = Object.fromEntries(
(req === undefined ? cookies() : req.cookies).getAll().map((cookie) => [cookie.name, cookie.value])
);

/**
* Pre parsed request is a wrapper exposed by SuperTokens. It is used as a helper to detect if the
* original request contains session tokens. We then use this pre parsed request to call `getSession`
* to check if there is a valid session.
*/
const baseRequest = new PreParsedRequest({
method: req === undefined ? 'get' : (req.method as HTTPMethod),
url: req === undefined ? '' : req.url,
query,
headers: req === undefined ? headers() : req.headers,
cookies: parsedCookies,
getFormBody: () => req!.formData(),
getJSONBody: () => req!.json(),
});

/**
* Collecting response is a wrapper exposed by SuperTokens. In this case we are using an empty
* CollectingResponse when calling `getSession`. If the request contains valid session tokens
* the SuperTokens SDK will attach all the relevant tokens to the collecting response object which
* we can then use to return those session tokens in the final result (refer to `withSession` in this file)
*/
const baseResponse = new CollectingResponse();

try {
/**
* `getSession` will throw if session is required and there is no valid session. You can use
* `options` to configure whether or not you want to require sessions when calling `getSSRSession`
*/
const session = await Session.getSession(baseRequest, baseResponse, options);
return {
session,
hasInvalidClaims: false,
hasToken: session !== undefined,
baseResponse,
};
} catch (error) {
if (Session.Error.isErrorFromSuperTokens(error)) {
return {
hasToken: error.type !== Session.Error.UNAUTHORISED,
hasInvalidClaims: error.type === Session.Error.INVALID_CLAIMS,
session: undefined,
baseResponse,
nextResponse: new NextResponse('Authentication required', {
status: error.type === Session.Error.INVALID_CLAIMS ? 403 : 401,
}),
};
} else {
throw error;
}
}
}

export async function withSession(
request: NextRequest,
handler: (session: SessionContainer | undefined) => Promise<NextResponse>,
options?: VerifySessionOptions
): Promise<NextResponse<unknown>> {
const { baseResponse, nextResponse, session } = await getSSRSession(request, options);
if (nextResponse) {
return nextResponse;
}

const userResponse = await handler(session);

let didAddCookies = false;
let didAddHeaders = false;

/**
* Base response is the response from SuperTokens that contains all the session tokens.
* We add all cookies and headers in the base response to the final response from the
* API to make sure sessions work correctly.
*/
for (const respCookie of baseResponse.cookies) {
didAddCookies = true;
userResponse.headers.append(
'Set-Cookie',
serialize(respCookie.key, respCookie.value, {
domain: respCookie.domain,
expires: new Date(respCookie.expires),
httpOnly: respCookie.httpOnly,
path: respCookie.path,
sameSite: respCookie.sameSite,
secure: respCookie.secure,
})
);
}

for (const [value, key] of baseResponse.headers) {
didAddHeaders = true;
userResponse.headers.set(key, value);
}

/**
* For some deployment services (Vercel for example) production builds can return cached results for
* APIs with older header values. In this case if the session tokens have changed (because of refreshing
* for example) the cached result would still contain the older tokens and sessions would stop working.
*
* As a result, if we add cookies or headers from base response we also set the Cache-Control header
* to make sure that the final result is not a cached version.
*/
if ((didAddCookies || didAddHeaders) && !userResponse.headers.has('Cache-Control')) {
// This is needed for production deployments with Vercel
userResponse.headers.set('Cache-Control', 'no-cache, no-store, max-age=0, must-revalidate');
}

return userResponse;
}
27 changes: 27 additions & 0 deletions src/components/molecules/HomeClientComponent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
'use client';

import React from 'react';
import { useSessionContext } from 'supertokens-auth-react/recipe/session';

export const HomeClientComponent: React.FC = () => {
const session = useSessionContext();

if (session.loading) {
return <div>Loading...</div>;
}

if (session.doesSessionExist === false) {
return <div>Session does not exist</div>;
}

return (
<div>
<div>
<p>
Client side component got userId: {session.userId}
<br />
</p>
</div>
</div>
);
};
19 changes: 19 additions & 0 deletions src/components/molecules/SessionAuthForNext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
'use client';

import React, { useEffect, useState } from 'react';
import { SessionAuth } from 'supertokens-auth-react/recipe/session';

type Props = Parameters<typeof SessionAuth>[0] & {
children?: React.ReactNode | undefined;
};

export const SessionAuthForNext: React.FC<Props> = (props: Props) => {
const [loaded, setLoaded] = useState(false);
useEffect(() => {
setLoaded(true);
}, []);
if (!loaded) {
return props.children;
}
return <SessionAuth {...props}>{props.children}</SessionAuth>;
};
43 changes: 43 additions & 0 deletions src/components/molecules/TryRefreshComponent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
'use client';

import { useRouter } from 'next/navigation';
import React, { useEffect, useState } from 'react';
import SuperTokens from 'supertokens-auth-react';
import Session from 'supertokens-auth-react/recipe/session';

export const TryRefreshComponent: React.FC = () => {
const router = useRouter();
const [didError, setDidError] = useState(false);

useEffect(() => {
/**
* `attemptRefreshingSession` will call the refresh token endpoint to try and
* refresh the session. This will throw an error if the session cannot be refreshed.
*/
void Session.attemptRefreshingSession()
.then((hasSession) => {
/**
* If the user has a valid session, we reload the page to restart the flow
* with valid session tokens
*/
if (hasSession) {
router.refresh();
} else {
SuperTokens.redirectToAuth();
}
})
.catch(() => {
setDidError(true);
});
}, [router]);

/**
* We add this check to make sure we handle the case where the refresh API fails with
* an unexpected error
*/
if (didError) {
return <div>Something went wrong, please reload the page</div>;
}

return <div>Loading...</div>;
};
14 changes: 14 additions & 0 deletions src/components/organisms/DefaultFooter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { BoxProps } from '@chakra-ui/react';
import { Box, HStack } from '@chakra-ui/react';

import { APP_AUTHOR } from '../../constants';

export const DefaultFooter: React.FC<BoxProps> = (props) => {
return (
<Box px={4} {...props}>
<HStack borderTopWidth={1} color="gray.500" fontSize="sm" py={4} spacing={8}>
<div>&copy; {APP_AUTHOR}</div>
</HStack>
</Box>
);
};
Loading

0 comments on commit 1bbd0f6

Please sign in to comment.