From 0b8ebbb6a0eccfb75f95d081193f92ef55d1e0dc Mon Sep 17 00:00:00 2001 From: Jiro Date: Tue, 27 Feb 2024 17:16:11 -0800 Subject: [PATCH 1/5] Fix infinit rendering on login --- frontend/core/src/app/login/content.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) 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 ( <> From 5b9790c924e8331fd73e5dce96b6108a53e15ee3 Mon Sep 17 00:00:00 2001 From: Jiro Date: Wed, 28 Feb 2024 18:38:28 -0800 Subject: [PATCH 2/5] fix useAuth --- frontend/core/src/app/layout.tsx | 8 +++--- frontend/core/src/app/login/layout.tsx | 26 ++++++++++++++++++ frontend/core/src/components/auth/index.tsx | 30 ++++++++++++++++++--- frontend/core/src/services/api/index.ts | 7 +++-- 4 files changed, 59 insertions(+), 12 deletions(-) create mode 100644 frontend/core/src/app/login/layout.tsx 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/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/components/auth/index.tsx b/frontend/core/src/components/auth/index.tsx index 16bf442..4416b5c 100644 --- a/frontend/core/src/components/auth/index.tsx +++ b/frontend/core/src/components/auth/index.tsx @@ -5,6 +5,7 @@ import { User } from "@/services/api/models/user"; import { useRouter } from "next/navigation"; import { useContext, useEffect, useState, createContext } from "react"; import route from "@/lib/route"; +import { AxiosError } from "axios"; interface IAuthContext { user?: User; @@ -18,7 +19,12 @@ const AuthContext = createContext({ initialized: false, }); -const AuthContainer = ({ children }: { children: React.ReactNode }) => { +interface Props { + children: React.ReactNode; + isPublic?: boolean; +} + +const AuthContainer = ({ children, isPublic }: Props) => { const router = useRouter(); const [user, setUser] = useState(); const [initialized, setInitialized] = useState(false); @@ -27,6 +33,7 @@ const AuthContainer = ({ children }: { children: React.ReactNode }) => { const init = async () => { try { if (!getAccessToken()) { + debugger const uuid = getUserId(); const r1 = await api.refreshToken({ uuid }); api.client.setAccessToken(r1.data.accessToken); @@ -35,9 +42,22 @@ const AuthContainer = ({ children }: { children: React.ReactNode }) => { const r2 = await api.fetchUser(); const user = factory.user(r2.data.data); setUser(user); - setInitialized(true); + if (isPublic) { + router.push(route.main.toString()); + } } catch (e) { - router.push(route.login.with("?error=loginRequired")); + const error = e as AxiosError; + if (!isPublic && error.response?.status === 401) { + router.push(route.login.with("?error=loginRequired")); + return; + } + + if (error.response?.status === 401) { + return; + } + throw e; + } finally { + setInitialized(true); } }; @@ -45,13 +65,15 @@ const AuthContainer = ({ children }: { children: React.ReactNode }) => { }, []); const logout = async () => { - await api.logout() + await api.logout(); setUser(undefined); api.client.setAccessToken(""); router.push(route.login.toString()); }; + console.log("initialized ===========", initialized); + if (!initialized) { return null; } diff --git a/frontend/core/src/services/api/index.ts b/frontend/core/src/services/api/index.ts index decee64..0774cf9 100644 --- a/frontend/core/src/services/api/index.ts +++ b/frontend/core/src/services/api/index.ts @@ -1,9 +1,7 @@ import axios, { AxiosError, AxiosInstance } from "axios"; import mockApi from "./mock"; -let accessToken: string; - -export const getAccessToken = () => accessToken; +export const getAccessToken = () => sessionStorage?.getItem('token') || ''; export const setUserId = (uuid: string) => localStorage.setItem("uuid", uuid); export const getUserId = () => localStorage.getItem("uuid") || ""; @@ -26,6 +24,7 @@ class Client { ...(config.headers || {}), }, }); + this._instance.defaults.headers["Authorization"] = `Bearer ${getAccessToken()}`; } get instance() { @@ -37,7 +36,7 @@ class Client { } setAccessToken = (token: string) => { - accessToken = token; + sessionStorage?.setItem('token', token) if (this._instance) { this._instance.defaults.headers["Authorization"] = `Bearer ${token}`; } From 886b3d54fb2e2893eaf6fea88a2af61e30f61e54 Mon Sep 17 00:00:00 2001 From: Jiro Date: Wed, 28 Feb 2024 18:53:54 -0800 Subject: [PATCH 3/5] some fix --- frontend/core/src/app/main/layout.module.css | 1 - .../core/src/components/shared/sidebar/index.module.css | 6 ------ frontend/core/src/services/api/index.ts | 6 ++++-- 3 files changed, 4 insertions(+), 9 deletions(-) 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/components/shared/sidebar/index.module.css b/frontend/core/src/components/shared/sidebar/index.module.css index 6efa3d2..557ce90 100644 --- a/frontend/core/src/components/shared/sidebar/index.module.css +++ b/frontend/core/src/components/shared/sidebar/index.module.css @@ -89,12 +89,6 @@ padding: 16px; } -.footer { - height: 40px; - background: var(--primary-color); - background: #f0f0f0; -} - .sidebarToggle { cursor: pointer; transition: transform .2s; diff --git a/frontend/core/src/services/api/index.ts b/frontend/core/src/services/api/index.ts index 0774cf9..c12fc57 100644 --- a/frontend/core/src/services/api/index.ts +++ b/frontend/core/src/services/api/index.ts @@ -1,7 +1,9 @@ import axios, { AxiosError, AxiosInstance } from "axios"; import mockApi from "./mock"; -export const getAccessToken = () => sessionStorage?.getItem('token') || ''; +const _sessionStorage = typeof sessionStorage !== 'undefined' ? sessionStorage : undefined + +export const getAccessToken = () => _sessionStorage?.getItem('token') || ''; export const setUserId = (uuid: string) => localStorage.setItem("uuid", uuid); export const getUserId = () => localStorage.getItem("uuid") || ""; @@ -36,7 +38,7 @@ class Client { } setAccessToken = (token: string) => { - sessionStorage?.setItem('token', token) + _sessionStorage?.setItem('token', token) if (this._instance) { this._instance.defaults.headers["Authorization"] = `Bearer ${token}`; } From 462c7f873c157f080c59719c395c9acfb56dd10c Mon Sep 17 00:00:00 2001 From: Jiro Date: Thu, 29 Feb 2024 19:29:01 -0800 Subject: [PATCH 4/5] Add auth controller test --- api/e2e | 4 + api/src/db/cli/seeds/index.ts | 13 +++ api/src/domains/auth/auth.controller.ts | 11 +- api/src/domains/auth/auth.service.ts | 10 +- api/src/domains/users/user.entity.ts | 11 ++ api/src/domains/users/users.controller.ts | 2 +- api/src/domains/users/users.service.ts | 2 + api/test/auth/auth.e2e-spec.ts | 105 ++++++++++++++++++ api/test/helper/databse.ts | 6 +- api/test/helper/index.ts | 2 + api/test/helper/request.ts | 6 +- api/test/users/users.e2e-spec.ts | 5 +- frontend/core/src/components/auth/index.tsx | 3 +- .../src/components/auth/loginForm/index.tsx | 5 +- 14 files changed, 161 insertions(+), 24 deletions(-) create mode 100755 api/e2e create mode 100644 api/test/auth/auth.e2e-spec.ts 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/components/auth/index.tsx b/frontend/core/src/components/auth/index.tsx index 4416b5c..7a93e03 100644 --- a/frontend/core/src/components/auth/index.tsx +++ b/frontend/core/src/components/auth/index.tsx @@ -33,10 +33,9 @@ const AuthContainer = ({ children, isPublic }: Props) => { const init = async () => { try { if (!getAccessToken()) { - debugger const uuid = getUserId(); const r1 = await api.refreshToken({ uuid }); - api.client.setAccessToken(r1.data.accessToken); + api.client.setAccessToken(r1.data.data.accessToken); } const r2 = await api.fetchUser(); diff --git a/frontend/core/src/components/auth/loginForm/index.tsx b/frontend/core/src/components/auth/loginForm/index.tsx index 4de38d5..65702c4 100644 --- a/frontend/core/src/components/auth/loginForm/index.tsx +++ b/frontend/core/src/components/auth/loginForm/index.tsx @@ -48,8 +48,9 @@ export default function Login() { onSubmit: async (values: Form) => { try { const res = await api.authenticate(values); - api.client.setAccessToken(res.accessToken); - setUserId(res.uuid) + const { data } = res + api.client.setAccessToken(data.accessToken); + setUserId(data.uuid) router.push("/main") } catch (e) { From ae76520dfde34239d995b1ce672f61ee01e06e5b Mon Sep 17 00:00:00 2001 From: Jiro Date: Tue, 12 Mar 2024 18:17:15 -0700 Subject: [PATCH 5/5] Update top page --- frontend/core/src/app/page.module.css | 65 +++++++++++++++++++++++++++ frontend/core/src/app/page.tsx | 60 +++++++++++++++++++++++++ 2 files changed, 125 insertions(+) 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円 / 月
+
+
+
+
+
+
無制限
+
+
+ +
+
+
+
+