diff --git a/api/e2e b/api/e2e new file mode 100755 index 0000000..30e1217 --- /dev/null +++ b/api/e2e @@ -0,0 +1,4 @@ +#!/bin/bash + +NODE_ENV=test node_modules/.bin/jest --config ./test/jest-e2e.json --detectOpenHandles $@ + diff --git a/api/src/db/cli/seeds/index.ts b/api/src/db/cli/seeds/index.ts index e5d2ea4..b91409d 100644 --- a/api/src/db/cli/seeds/index.ts +++ b/api/src/db/cli/seeds/index.ts @@ -13,6 +13,18 @@ export const seed = async ({ appFactory, dataSource, logger }) => { logger.info('connection is establised'); const users = []; + const userIds = [ + 'fa66f863-1040-48bd-a156-11bb7cce796e', + '4e5eb084-0499-47f8-a0d9-79603002e1bd', + '95bab443-1fda-4d63-90ca-2e13296650af', + '0060f247-3223-4512-9ce1-6bfaa18a2579', + '4195c4df-47fd-4b1b-b952-c7c38aa8f27f', + 'f4d43b04-998d-4fcc-9afc-0dcbd0a5673a', + '1fe031c0-bf34-4684-ae29-baa9cf242398', + '03129556-4243-4c68-bdc5-b71c0cb129a3', + 'c45beb14-3b18-4841-b1cc-ba6a84ac3067', + '9be550a1-7d6f-4488-af0d-a60989e90d3a', + ]; await dataSource.transaction(async (manager: EntityManager) => { logger.info('[START] users ========'); for (let i = 1; i <= 10; i++) { @@ -20,6 +32,7 @@ export const seed = async ({ appFactory, dataSource, logger }) => { const user = manager.create(User, { username: `user ${i}`, email: `user.${i}@example.com`, + uuid: userIds[i - 1], createdAt: timestamp, updatedAt: timestamp, status: 'active', diff --git a/api/src/domains/auth/auth.controller.ts b/api/src/domains/auth/auth.controller.ts index 24ef90c..c4f6d8e 100644 --- a/api/src/domains/auth/auth.controller.ts +++ b/api/src/domains/auth/auth.controller.ts @@ -28,13 +28,13 @@ export class AuthController { @Res({ passthrough: true }) res: Response, ) { const { email, password, rememberMe } = body; - const json = await this.authService.signIn(email, password); + const data = await this.authService.signIn(email, password); if (rememberMe) { - this.setRefreshToken(res, json.refreshToken); + this.setRefreshToken(res, data.refreshToken); } - return json; + return { data }; } @Public() @@ -54,12 +54,12 @@ export class AuthController { }); if (!result) { response.status(401); - return { message: 'invalid refresh token' }; + return { message: 'invalid refresh token or uuid' }; } this.setRefreshToken(response, result.refreshToken); - return result; + return { data: result }; } catch (e) { response.status(401); this.loggerService.logger.info(e); @@ -72,7 +72,6 @@ export class AuthController { @Delete('refresh') async clearRefreshToken(@Res({ passthrough: true }) response: Response) { this.removeRefreshToken(response); - response.status(200); } private extractTokenFromCookie(request: Request): string | undefined { diff --git a/api/src/domains/auth/auth.service.ts b/api/src/domains/auth/auth.service.ts index 40cbf7f..ddb03ae 100644 --- a/api/src/domains/auth/auth.service.ts +++ b/api/src/domains/auth/auth.service.ts @@ -51,17 +51,17 @@ export class AuthService { } async token(user: User) { - await this.usersService.updateRefreshToken(user); + const _user = await this.usersService.updateRefreshToken(user); const payload = { - sub: user.username, - refreshToken: user.refreshToken, + sub: _user.username, + refreshToken: _user.refreshToken, }; return { - uuid: user.uuid, + uuid: _user.uuid, accessToken: await this.jwtService.signAsync(payload), - refreshToken: user.refreshToken, + refreshToken: _user.refreshToken, }; } diff --git a/api/src/domains/users/user.entity.ts b/api/src/domains/users/user.entity.ts index 3f503b8..ae3e231 100644 --- a/api/src/domains/users/user.entity.ts +++ b/api/src/domains/users/user.entity.ts @@ -36,4 +36,15 @@ export class User extends Base { @OneToMany(() => Task, (task) => task.user) tasks: Task[]; + + get serialize() { + return { + uuid: this.uuid, + username: this.username, + email: this.email, + status: this.status, + createdAt: this.createdAt, + updatedAt: this.updatedAt, + }; + } } diff --git a/api/src/domains/users/users.controller.ts b/api/src/domains/users/users.controller.ts index 69d92d0..6f0f003 100644 --- a/api/src/domains/users/users.controller.ts +++ b/api/src/domains/users/users.controller.ts @@ -6,6 +6,6 @@ export class UserController { async show(@Req() request: Request): Promise> { const user = request['user']; - return { data: user }; + return { data: user.serialize }; } } diff --git a/api/src/domains/users/users.service.ts b/api/src/domains/users/users.service.ts index c38168f..f81bb8a 100644 --- a/api/src/domains/users/users.service.ts +++ b/api/src/domains/users/users.service.ts @@ -68,6 +68,8 @@ export class UsersService { async updateRefreshToken(user: User) { user.refreshToken = await this.hashRefreshToken(); await this.manager.save(user); + + return user; } async hash(user: User, password: string) { diff --git a/api/test/auth/auth.e2e-spec.ts b/api/test/auth/auth.e2e-spec.ts new file mode 100644 index 0000000..9737789 --- /dev/null +++ b/api/test/auth/auth.e2e-spec.ts @@ -0,0 +1,105 @@ +import { INestApplication } from '@nestjs/common'; +import { prepareApp } from '../helper'; +import request from '../helper/request'; + +describe('AuthController (e2e)', () => { + let app: INestApplication; + let server: any; + + beforeAll(async () => { + app = await prepareApp([]); + + server = app?.getHttpServer(); + return; + }); + + describe('Post /auth/login', () => { + const subject = () => request(server).post('/auth/login'); + + it(`with right auth info, return 200`, async () => { + const response = await subject().send({ + email: 'user.1@example.com', + password: 'AndyBobCharrie', + }); + + expect(response.status).toEqual(200); + expect(response.body).toMatchObject({ + data: { + uuid: 'fa66f863-1040-48bd-a156-11bb7cce796e', + accessToken: expect.any(String), + refreshToken: expect.any(String), + }, + }); + }); + + it(`with wrong mail address, return 401: 1`, async () => { + const response = await subject().send({ + email: 'wrong@example.com', + password: 'AndyBobCharrie', + }); + + expect(response.status).toEqual(401); + }); + + it(`with wrong password, return 401: 2`, async () => { + const response = await subject().send({ + email: 'user.1@example.com', + password: 'wrongpassword', + }); + + expect(response.status).toEqual(401); + }); + }); + + describe('Post /auth/refresh', () => { + const subject = () => request(server).post('/auth/refresh'); + + it(`with right refreshToken, return 200`, async () => { + const client = request(server); + const r1 = await client.post('/auth/login').send({ + email: 'user.1@example.com', + password: 'AndyBobCharrie', + }); + + expect(r1.status).toEqual(200); + const { uuid, refreshToken } = r1.body.data; + + const response = await client + .post('/auth/refresh') + .set('Cookie', `refreshToken=${refreshToken}`) + .send({ + uuid, + }); + expect(response.status).toEqual(200); + expect(response.body).toMatchObject({ + data: { + uuid: 'fa66f863-1040-48bd-a156-11bb7cce796e', + accessToken: expect.any(String), + refreshToken: expect.any(String), + }, + }); + expect(response.body.data.refreshToken).not.toEqual(refreshToken); + }); + + it(`with wrong refreshToken, return 401: 1`, async () => { + const uuid = 'fa66f863-1040-48bd-a156-11bb7cce796e'; + const response = await subject().withAuth().send({ + uuid, + }); + + expect(response.status).toEqual(401); + }); + }); + + describe('Delete /auth/refresh', () => { + const subject = () => request(server).delete('/auth/refresh'); + it(`return 200`, async () => { + const response = await subject(); + expect(response.status).toEqual(200); + }); + }); + + afterAll(async () => { + await app.close(); + }); +}); diff --git a/api/test/helper/databse.ts b/api/test/helper/databse.ts index b7a68e5..0a65a2c 100644 --- a/api/test/helper/databse.ts +++ b/api/test/helper/databse.ts @@ -1,6 +1,6 @@ import { INestApplication } from '@nestjs/common'; import { getDataSourceToken, getRepositoryToken } from '@nestjs/typeorm'; -import { DataSource, EntityManager } from 'typeorm'; +import { DataSource, EntityManager, Repository } from 'typeorm'; class RollbackError extends Error {} @@ -28,6 +28,6 @@ export const withCleanup = async ( } }; -export const getRepository = (app: INestApplication, token: any) => { - return app.get(getRepositoryToken(token)); +export const getRepository = (app: INestApplication, token: any) => { + return app.get(getRepositoryToken(token)) as Repository; }; diff --git a/api/test/helper/index.ts b/api/test/helper/index.ts index 6a7f2fb..63b7db6 100644 --- a/api/test/helper/index.ts +++ b/api/test/helper/index.ts @@ -5,6 +5,7 @@ import { dataSourceOptions } from '../../src/db/config'; import appConfig from '../../src/config/app.config'; import { AppModule } from '../../src/app.module'; import { Test } from './request'; +import cookieParser from 'cookie-parser'; export const checkNoAuthBehavior = (test: () => Test): [string, () => Test] => [ 'no auth token, return 401', @@ -39,6 +40,7 @@ export const prepareApp = async (providers: OverrideProvier[]) => { const module = await moduleFixture.compile(); const app = module.createNestApplication(); + app.use(cookieParser()); await app.init(); return app; diff --git a/api/test/helper/request.ts b/api/test/helper/request.ts index 1d11e2d..a56cc1a 100644 --- a/api/test/helper/request.ts +++ b/api/test/helper/request.ts @@ -27,7 +27,7 @@ export type Test = ExtendedTest & supertest.Test; const testFactory = (test: supertest.Test): Test => new Proxy(new ExtendedTest(test), handler) as Test; -class ExtendedRequest { +export class ExtendedRequest { _request: supertest.SuperTest; constructor(app: any) { @@ -49,6 +49,10 @@ class ExtendedRequest { put(path: string) { return testFactory(this._request.put(path)); } + + delete(path: string) { + return testFactory(this._request.delete(path)); + } } export default function (app: any) { diff --git a/api/test/users/users.e2e-spec.ts b/api/test/users/users.e2e-spec.ts index 6986814..f582a2b 100644 --- a/api/test/users/users.e2e-spec.ts +++ b/api/test/users/users.e2e-spec.ts @@ -31,14 +31,11 @@ describe('UsersController (e2e)', () => { expect(response.status).toEqual(200); expect(response.body).toMatchObject({ data: { - id: 1, - uuid: expect.any(String), + uuid: 'fa66f863-1040-48bd-a156-11bb7cce796e', createdAt: expect.any(String), updatedAt: expect.any(String), username: 'user 1', email: 'user.1@example.com', - password: expect.any(String), - refreshToken: expect.any(String), status: 'active', }, }); diff --git a/frontend/core/src/app/layout.tsx b/frontend/core/src/app/layout.tsx index 360f0cc..a6bd076 100644 --- a/frontend/core/src/app/layout.tsx +++ b/frontend/core/src/app/layout.tsx @@ -3,11 +3,11 @@ import { Inter } from "next/font/google"; const inter = Inter({ subsets: ["latin"] }); -import { Metadata } from 'next' +import { Metadata } from "next"; export const metadata: Metadata = { - title: 'Turbo', -} + title: "Turbo", +}; export default function RootLayout({ children, @@ -15,7 +15,7 @@ export default function RootLayout({ children: React.ReactNode; }) { return ( - + {children} ); diff --git a/frontend/core/src/app/login/content.tsx b/frontend/core/src/app/login/content.tsx index cd482f1..b03fdb2 100644 --- a/frontend/core/src/app/login/content.tsx +++ b/frontend/core/src/app/login/content.tsx @@ -1,6 +1,6 @@ "use client"; import { useSearchParams } from "next/navigation"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import styles from "./page.module.css"; import Header from "@/components/shared/header/public"; import Footer from "@/components/shared/footer"; @@ -12,15 +12,17 @@ import { useToast } from "@/lib/toast/hook"; const Content = () => { const searchParams = useSearchParams(); const toast = useToast(); + const [rendered, setRendered] = useState(false); useEffect(() => { const error = searchParams.get("error"); if (error) { - if (error === "loginRequired") { + if (!rendered && error === "loginRequired") { toast.error("ログインが必要です"); } } - }, [searchParams, toast]); + setRendered(true); + }, [searchParams, toast, rendered]); return ( <> diff --git a/frontend/core/src/app/login/layout.tsx b/frontend/core/src/app/login/layout.tsx new file mode 100644 index 0000000..05df4e3 --- /dev/null +++ b/frontend/core/src/app/login/layout.tsx @@ -0,0 +1,26 @@ +import { Inter } from "next/font/google"; + +const inter = Inter({ subsets: ["latin"] }); + +import { Metadata } from "next"; +import AuthContainer from "@/components/auth"; + +export const metadata: Metadata = { + title: "Turbo", +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + {children} + + + ); +} + +export const dynamic = "error"; diff --git a/frontend/core/src/app/main/layout.module.css b/frontend/core/src/app/main/layout.module.css index 06e5841..276d263 100644 --- a/frontend/core/src/app/main/layout.module.css +++ b/frontend/core/src/app/main/layout.module.css @@ -1,5 +1,4 @@ .body { - height: calc(100vh - 72px); } .main { diff --git a/frontend/core/src/app/page.module.css b/frontend/core/src/app/page.module.css index 5e052f7..8fa8535 100644 --- a/frontend/core/src/app/page.module.css +++ b/frontend/core/src/app/page.module.css @@ -57,3 +57,68 @@ button.entry { padding: 16px; width: 360px; } + +.price { + margin: 64px 0; + width: 100%; +} + +.priceContent { + display: flex; + width: 100%; + justify-content: center; +} + +.planName { + font-size: 14px; +} + +.amount { + font-size: 24px; + padding: 32px 0; +} + +.priceHeader { + margin: 32px 0px; +} + +.priceTitle { + text-align: center; + font-weight: normal; +} + +.priceItem { + padding: 32px; + margin-right: 32px; + border: 1px solid var(--border-color); + border-radius: 4px; + min-width: 30%; +} + +.priceList { + display: flex; + justify-content: center; + min-width: 70%; +} + +.table { + border-top: 1px solid var(--border-color); +} + +.tableRow { + padding: 16px 0; + text-align: center; + border-bottom: 1px solid var(--border-color); +} + +.features { +} + +.featureList { + margin-top: 160px; +} + +.feature { + padding: 16px 0; + margin-bottom: 1px solid var(--border-color); +} diff --git a/frontend/core/src/app/page.tsx b/frontend/core/src/app/page.tsx index a0e7a15..fc265a7 100644 --- a/frontend/core/src/app/page.tsx +++ b/frontend/core/src/app/page.tsx @@ -62,6 +62,66 @@ export default function Home() { image={img3} imageAlt="ゴール設定" /> +
+
+

料金

+
+
+
+
+
目標設定・計画・タスク管理 UI
+
タスクのグラフ化
+
リマインダー
+
カンバン方式のタスク管理
+
ファイルのアップロード制限
+
+
+
+
+
無料プラン
+
無料
+
+
+
+
+
-
+
500MB まで
+
+
+ +
+
+
+
有料プラン
+
499円 / 月
+
+
+
+
+
+
2GBまで
+
+
+ +
+
+
+
プレミアムプラン
+
2, 980円 / 月
+
+
+
+
+
+
無制限
+
+
+ +
+
+
+
+