@sayren/mcp 0.1.5 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/template/.template-meta.json +2 -2
- package/template/app/lib/social-login.server.ts +38 -0
- package/template/app/routes/auth-callback.tsx +44 -0
- package/template/app/routes/cart.tsx +40 -16
- package/template/app/routes/checkout.tsx +12 -2
- package/template/app/routes/login.tsx +106 -27
- package/template/app/routes.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sayren/mcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"sayren-mcp": "./dist/index.mjs"
|
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
"dependencies": {
|
|
13
13
|
"@modelcontextprotocol/sdk": "^1.22.0",
|
|
14
14
|
"zod": "^4.6.5",
|
|
15
|
-
"@sayren/store-sdk": "^0.
|
|
16
|
-
"@sayren/storefront-sdk": "^0.
|
|
15
|
+
"@sayren/store-sdk": "^0.5.0",
|
|
16
|
+
"@sayren/storefront-sdk": "^0.6.0"
|
|
17
17
|
},
|
|
18
18
|
"devDependencies": {
|
|
19
19
|
"@biomejs/biome": "^2.5.14",
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { createCookie } from "react-router";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 소셜 로그인 왕복 상태 — 공급자로 보내기 전에 만든 state·PKCE verifier·돌아갈 곳을 HttpOnly 쿠키에 잠깐 둔다.
|
|
5
|
+
* 돌아오면 state를 비교하고 verifier로 1회용 code를 토큰으로 바꾼 뒤 지운다. 10분이 지나면 사라진다.
|
|
6
|
+
*/
|
|
7
|
+
export const socialLoginCookie = createCookie("sayren_social", {
|
|
8
|
+
httpOnly: true,
|
|
9
|
+
sameSite: "lax",
|
|
10
|
+
path: "/",
|
|
11
|
+
secure: process.env.NODE_ENV === "production",
|
|
12
|
+
maxAge: 60 * 10,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export interface SocialLoginState {
|
|
16
|
+
state: string;
|
|
17
|
+
codeVerifier: string;
|
|
18
|
+
redirectTo: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function readSocialLogin(request: Request): Promise<SocialLoginState | null> {
|
|
22
|
+
const value: unknown = await socialLoginCookie.parse(request.headers.get("cookie"));
|
|
23
|
+
if (!value || typeof value !== "object") return null;
|
|
24
|
+
const stored = value as Partial<SocialLoginState>;
|
|
25
|
+
if (typeof stored.state !== "string" || typeof stored.codeVerifier !== "string") return null;
|
|
26
|
+
return {
|
|
27
|
+
state: stored.state,
|
|
28
|
+
codeVerifier: stored.codeVerifier,
|
|
29
|
+
redirectTo: typeof stored.redirectTo === "string" ? stored.redirectTo : "/",
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 공급자 표시 — 버튼 문구·색. 로고는 각 사 가이드의 공식 버튼 자산으로 바꿔 쓴다 */
|
|
34
|
+
export const SOCIAL_BUTTONS = {
|
|
35
|
+
kakao: { label: "카카오로 시작하기", className: "bg-[#FEE500] text-[#191600]" },
|
|
36
|
+
naver: { label: "네이버로 시작하기", className: "bg-[#03A94D] text-white" },
|
|
37
|
+
google: { label: "구글로 시작하기", className: "border border-line bg-white text-ink" },
|
|
38
|
+
} as const;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { ApiError } from "@sayren/storefront-sdk";
|
|
2
|
+
import { IdpCallbackError } from "@sayren/storefront-sdk/auth";
|
|
3
|
+
import { redirect } from "react-router";
|
|
4
|
+
import { authFor } from "../lib/api.server";
|
|
5
|
+
import { cartCookie, readCartToken } from "../lib/cart-session.server";
|
|
6
|
+
import { signInCookie } from "../lib/session.server";
|
|
7
|
+
import { readSocialLogin, socialLoginCookie } from "../lib/social-login.server";
|
|
8
|
+
import type { Route } from "./+types/auth-callback";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 소셜 로그인에서 돌아오는 곳 — 쿠키에 둔 state·verifier로 1회용 code를 구매자 토큰으로 바꾸고 세션 쿠키를 심는다.
|
|
12
|
+
* 비회원 장바구니가 있으면 합친다. 실패하면 로그인 화면으로 이유를 들고 돌아간다.
|
|
13
|
+
*/
|
|
14
|
+
export async function loader({ request }: Route.LoaderArgs) {
|
|
15
|
+
const url = new URL(request.url);
|
|
16
|
+
const stored = await readSocialLogin(request);
|
|
17
|
+
const clearSocial = await socialLoginCookie.serialize("", { maxAge: 0 });
|
|
18
|
+
const fail = (reason: string) =>
|
|
19
|
+
redirect(`/login?error=${encodeURIComponent(reason)}`, {
|
|
20
|
+
headers: { "Set-Cookie": clearSocial },
|
|
21
|
+
});
|
|
22
|
+
if (!stored) return fail("state_mismatch");
|
|
23
|
+
|
|
24
|
+
const cartToken = await readCartToken(request);
|
|
25
|
+
try {
|
|
26
|
+
const tokens = await authFor(request).idpCallback({
|
|
27
|
+
code: url.searchParams.get("code"),
|
|
28
|
+
state: url.searchParams.get("state"),
|
|
29
|
+
error: url.searchParams.get("error"),
|
|
30
|
+
expectedState: stored.state,
|
|
31
|
+
codeVerifier: stored.codeVerifier,
|
|
32
|
+
cartToken: cartToken ?? undefined,
|
|
33
|
+
});
|
|
34
|
+
const headers = new Headers();
|
|
35
|
+
headers.append("Set-Cookie", await signInCookie(tokens));
|
|
36
|
+
headers.append("Set-Cookie", clearSocial);
|
|
37
|
+
if (cartToken) headers.append("Set-Cookie", await cartCookie.serialize("", { maxAge: 0 }));
|
|
38
|
+
return redirect(stored.redirectTo, { headers });
|
|
39
|
+
} catch (error) {
|
|
40
|
+
if (error instanceof IdpCallbackError) return fail(error.reason);
|
|
41
|
+
if (error instanceof ApiError) return fail("provider_error");
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -14,10 +14,15 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
|
|
14
14
|
const cartToken = await readCartToken(request);
|
|
15
15
|
// 비회원이 아직 장바구니를 만든 적이 없으면 서버에 물어볼 것이 없다
|
|
16
16
|
if (!accessToken && !cartToken) {
|
|
17
|
-
return {
|
|
17
|
+
return {
|
|
18
|
+
cart: { items: [], summary: { productAmount: 0, deliveryFee: 0, totalAmount: 0 } },
|
|
19
|
+
loginRequired: false,
|
|
20
|
+
};
|
|
18
21
|
}
|
|
19
22
|
const api = apiFor(request, { accessToken, cartToken });
|
|
20
|
-
|
|
23
|
+
// 회원만 주문받는 스토어면 비회원에게 주문 버튼 대신 로그인 안내를 보인다(담기는 그대로 된다)
|
|
24
|
+
const [cart, store] = await Promise.all([api.cart.get(), accessToken ? null : api.store.get()]);
|
|
25
|
+
return { cart, loginRequired: store ? !store.guestCheckout : false };
|
|
21
26
|
}
|
|
22
27
|
|
|
23
28
|
export async function action({ request, context }: Route.ActionArgs) {
|
|
@@ -49,7 +54,7 @@ export async function action({ request, context }: Route.ActionArgs) {
|
|
|
49
54
|
}
|
|
50
55
|
|
|
51
56
|
export default function CartPage({ loaderData }: Route.ComponentProps) {
|
|
52
|
-
const { cart } = loaderData;
|
|
57
|
+
const { cart, loginRequired } = loaderData;
|
|
53
58
|
|
|
54
59
|
if (!cart.items.length) {
|
|
55
60
|
return (
|
|
@@ -142,19 +147,38 @@ export default function CartPage({ loaderData }: Route.ComponentProps) {
|
|
|
142
147
|
</div>
|
|
143
148
|
</dl>
|
|
144
149
|
|
|
145
|
-
{
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
</
|
|
157
|
-
|
|
150
|
+
{loginRequired ? (
|
|
151
|
+
<div className="flex w-full flex-col gap-2 md:w-64">
|
|
152
|
+
<p className="text-center text-muted text-xs">
|
|
153
|
+
이 스토어는 회원만 주문할 수 있어요. 담은 상품은 로그인하면 그대로 이어져요
|
|
154
|
+
</p>
|
|
155
|
+
<Link
|
|
156
|
+
to="/login?redirectTo=/cart"
|
|
157
|
+
className="flex h-12 items-center justify-center rounded-md bg-ink font-semibold text-white"
|
|
158
|
+
>
|
|
159
|
+
로그인하고 주문하기
|
|
160
|
+
</Link>
|
|
161
|
+
</div>
|
|
162
|
+
) : (
|
|
163
|
+
/* 주문서는 GET으로 연다 — 진입 조건이 URL에 남아 새로고침·뒤로가기가 그대로 동작한다 */
|
|
164
|
+
<Form method="get" action="/checkout" className="w-full md:w-64">
|
|
165
|
+
{purchasable.map((item) => (
|
|
166
|
+
<input
|
|
167
|
+
key={item.cartItemId}
|
|
168
|
+
type="hidden"
|
|
169
|
+
name="cartItemId"
|
|
170
|
+
value={item.cartItemId}
|
|
171
|
+
/>
|
|
172
|
+
))}
|
|
173
|
+
<button
|
|
174
|
+
type="submit"
|
|
175
|
+
disabled={!purchasable.length}
|
|
176
|
+
className="h-12 w-full rounded-md bg-ink font-semibold text-white disabled:opacity-50"
|
|
177
|
+
>
|
|
178
|
+
주문하기
|
|
179
|
+
</button>
|
|
180
|
+
</Form>
|
|
181
|
+
)}
|
|
158
182
|
</div>
|
|
159
183
|
</div>
|
|
160
184
|
);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ApiError } from "@sayren/storefront-sdk";
|
|
1
2
|
import { useEffect, useRef, useState } from "react";
|
|
2
3
|
import { Form, redirect, useNavigate } from "react-router";
|
|
3
4
|
import { browserApi } from "../lib/api";
|
|
@@ -15,7 +16,8 @@ export function meta() {
|
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
export async function loader({ request, context }: Route.LoaderArgs) {
|
|
18
|
-
const
|
|
19
|
+
const url = new URL(request.url);
|
|
20
|
+
const intent = checkoutIntentFrom(url.searchParams);
|
|
19
21
|
if (!intent) throw redirect("/cart");
|
|
20
22
|
|
|
21
23
|
const accessToken = readToken(context);
|
|
@@ -23,7 +25,15 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
|
|
23
25
|
const api = apiFor(request, { accessToken, cartToken });
|
|
24
26
|
// 주문서 세션은 진입할 때마다 새로 만든다 — 조회 API가 없고, 가격·재고를 매번 다시 계산한다
|
|
25
27
|
const [checkout, member] = await Promise.all([
|
|
26
|
-
api.checkout.create(intent)
|
|
28
|
+
api.checkout.create(intent).catch((error: unknown) => {
|
|
29
|
+
// 회원만 주문받는 스토어 — 로그인 뒤 돌아온다. 장바구니 주문은 로그인하며 장바구니가 합쳐져 항목 id가
|
|
30
|
+
// 바뀔 수 있으니 장바구니로, 바로구매는 같은 주문서로 돌아온다
|
|
31
|
+
if (error instanceof ApiError && error.code === "GUEST_CHECKOUT_DISABLED") {
|
|
32
|
+
const back = intent.cartItemIds ? "/cart" : `${url.pathname}${url.search}`;
|
|
33
|
+
throw redirect(`/login?redirectTo=${encodeURIComponent(back)}`);
|
|
34
|
+
}
|
|
35
|
+
throw error;
|
|
36
|
+
}),
|
|
27
37
|
accessToken ? api.member.me().catch(() => null) : null,
|
|
28
38
|
]);
|
|
29
39
|
return {
|
|
@@ -1,21 +1,57 @@
|
|
|
1
|
-
import { ApiError } from "@sayren/storefront-sdk";
|
|
2
|
-
import { Form, redirect } from "react-router";
|
|
3
|
-
import { authFor } from "../lib/api.server";
|
|
1
|
+
import { ApiError, type SocialProvider } from "@sayren/storefront-sdk";
|
|
2
|
+
import { Form, redirect, useSearchParams } from "react-router";
|
|
3
|
+
import { apiFor, authFor } from "../lib/api.server";
|
|
4
4
|
import { cartCookie, readCartToken } from "../lib/cart-session.server";
|
|
5
5
|
import { safeRedirect } from "../lib/safe-redirect";
|
|
6
6
|
import { signInCookie } from "../lib/session.server";
|
|
7
|
+
import { SOCIAL_BUTTONS, socialLoginCookie } from "../lib/social-login.server";
|
|
7
8
|
import type { Route } from "./+types/login";
|
|
8
9
|
|
|
9
10
|
export function meta() {
|
|
10
11
|
return [{ title: "로그인" }];
|
|
11
12
|
}
|
|
12
13
|
|
|
14
|
+
/** 켜진 로그인 방법 — 셀러가 콘솔 설정 › 로그인에서 켠 것만 온다 */
|
|
15
|
+
export async function loader({ request }: Route.LoaderArgs) {
|
|
16
|
+
const store = await apiFor(request).store.get();
|
|
17
|
+
return { loginMethods: store.loginMethods };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 소셜 로그인 시작 — state·PKCE verifier를 쿠키에 두고 공급자 로그인 화면으로 보낸다. 돌아오는 곳은 `/auth/callback`이고,
|
|
22
|
+
* 이 앱의 주소가 콘솔의 스토어프런트 도메인에 등록돼 있어야 한다.
|
|
23
|
+
*/
|
|
24
|
+
async function startSocial(request: Request, provider: SocialProvider, redirectTo: string) {
|
|
25
|
+
const origin = new URL(request.url).origin;
|
|
26
|
+
const started = await authFor(request).idp(provider, { redirectUri: `${origin}/auth/callback` });
|
|
27
|
+
return redirect(started.url, {
|
|
28
|
+
headers: {
|
|
29
|
+
"Set-Cookie": await socialLoginCookie.serialize({
|
|
30
|
+
state: started.state,
|
|
31
|
+
codeVerifier: started.codeVerifier,
|
|
32
|
+
redirectTo,
|
|
33
|
+
}),
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
13
38
|
/**
|
|
14
39
|
* 로그인 — 화면은 이 앱이 그리고, 확인은 sayren이 한다. 비회원으로 담아 둔 장바구니 토큰을 함께 보내면
|
|
15
40
|
* 회원 장바구니로 합쳐지고 그 토큰은 닫힌다. 그래서 장바구니 쿠키도 지운다.
|
|
16
41
|
*/
|
|
17
42
|
export async function action({ request }: Route.ActionArgs) {
|
|
18
43
|
const form = await request.formData();
|
|
44
|
+
const redirectTo = safeRedirect(
|
|
45
|
+
form.get("redirectTo") ?? new URL(request.url).searchParams.get("redirectTo"),
|
|
46
|
+
);
|
|
47
|
+
const social = form.get("social");
|
|
48
|
+
if (social === "kakao" || social === "naver" || social === "google") {
|
|
49
|
+
try {
|
|
50
|
+
return await startSocial(request, social, redirectTo);
|
|
51
|
+
} catch (error) {
|
|
52
|
+
return { error: loginErrorMessage(error) };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
19
55
|
const cartToken = await readCartToken(request);
|
|
20
56
|
try {
|
|
21
57
|
const tokens = await authFor(request).session({
|
|
@@ -26,9 +62,7 @@ export async function action({ request }: Route.ActionArgs) {
|
|
|
26
62
|
const headers = new Headers();
|
|
27
63
|
headers.append("Set-Cookie", await signInCookie(tokens));
|
|
28
64
|
if (cartToken) headers.append("Set-Cookie", await cartCookie.serialize("", { maxAge: 0 }));
|
|
29
|
-
|
|
30
|
-
form.get("redirectTo") ?? new URL(request.url).searchParams.get("redirectTo");
|
|
31
|
-
return redirect(safeRedirect(redirectTo), { headers });
|
|
65
|
+
return redirect(redirectTo, { headers });
|
|
32
66
|
} catch (error) {
|
|
33
67
|
return { error: loginErrorMessage(error) };
|
|
34
68
|
}
|
|
@@ -37,6 +71,7 @@ export async function action({ request }: Route.ActionArgs) {
|
|
|
37
71
|
function loginErrorMessage(error: unknown): string {
|
|
38
72
|
if (error instanceof ApiError) {
|
|
39
73
|
if (error.code === "INVALID_CREDENTIALS") return "이메일이나 비밀번호가 맞지 않아요";
|
|
74
|
+
if (error.code === "PASSWORD_LOGIN_DISABLED") return "이 스토어는 소셜 로그인만 받아요";
|
|
40
75
|
if (error.code === "ACCOUNT_LOCKED")
|
|
41
76
|
return "로그인을 여러 번 실패해 잠시 잠겼어요. 10분 뒤에 다시 시도해 주세요";
|
|
42
77
|
if (error.status === 400) return "이메일과 비밀번호를 확인해 주세요";
|
|
@@ -44,30 +79,74 @@ function loginErrorMessage(error: unknown): string {
|
|
|
44
79
|
return "로그인하지 못했어요. 잠시 후 다시 시도해 주세요";
|
|
45
80
|
}
|
|
46
81
|
|
|
47
|
-
|
|
82
|
+
const CALLBACK_ERRORS: Record<string, string> = {
|
|
83
|
+
access_denied: "로그인을 취소했어요",
|
|
84
|
+
state_mismatch: "로그인 요청이 만료됐어요. 다시 시도해 주세요",
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export default function Login({ loaderData, actionData }: Route.ComponentProps) {
|
|
88
|
+
const [params] = useSearchParams();
|
|
89
|
+
const callbackError = params.get("error");
|
|
90
|
+
const social = loaderData.loginMethods.filter(
|
|
91
|
+
(method): method is SocialProvider => method !== "password",
|
|
92
|
+
);
|
|
93
|
+
const password = loaderData.loginMethods.includes("password");
|
|
94
|
+
const error =
|
|
95
|
+
actionData?.error ??
|
|
96
|
+
(callbackError
|
|
97
|
+
? (CALLBACK_ERRORS[callbackError] ?? "로그인하지 못했어요. 다시 시도해 주세요")
|
|
98
|
+
: null);
|
|
99
|
+
|
|
48
100
|
return (
|
|
49
101
|
<div className="mx-auto max-w-sm space-y-5 py-10">
|
|
50
102
|
<h1 className="font-bold text-2xl">로그인</h1>
|
|
51
|
-
|
|
52
|
-
<
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
<
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
103
|
+
{social.length ? (
|
|
104
|
+
<Form method="post" className="space-y-2">
|
|
105
|
+
{social.map((provider) => (
|
|
106
|
+
<button
|
|
107
|
+
key={provider}
|
|
108
|
+
type="submit"
|
|
109
|
+
name="social"
|
|
110
|
+
value={provider}
|
|
111
|
+
className={`h-12 w-full rounded-md font-semibold ${SOCIAL_BUTTONS[provider].className}`}
|
|
112
|
+
>
|
|
113
|
+
{SOCIAL_BUTTONS[provider].label}
|
|
114
|
+
</button>
|
|
115
|
+
))}
|
|
116
|
+
</Form>
|
|
117
|
+
) : null}
|
|
118
|
+
{social.length && password ? (
|
|
119
|
+
<div className="flex items-center gap-3 text-muted text-xs">
|
|
120
|
+
<span className="h-px flex-1 bg-line" />
|
|
121
|
+
또는 이메일로
|
|
122
|
+
<span className="h-px flex-1 bg-line" />
|
|
123
|
+
</div>
|
|
124
|
+
) : null}
|
|
125
|
+
{password ? (
|
|
126
|
+
<Form method="post" className="space-y-3">
|
|
127
|
+
<input
|
|
128
|
+
type="email"
|
|
129
|
+
name="email"
|
|
130
|
+
placeholder="이메일"
|
|
131
|
+
required
|
|
132
|
+
className="h-11 w-full rounded-md border border-line px-3"
|
|
133
|
+
/>
|
|
134
|
+
<input
|
|
135
|
+
type="password"
|
|
136
|
+
name="password"
|
|
137
|
+
placeholder="비밀번호"
|
|
138
|
+
required
|
|
139
|
+
className="h-11 w-full rounded-md border border-line px-3"
|
|
140
|
+
/>
|
|
141
|
+
<button type="submit" className="h-12 w-full rounded-md bg-ink font-semibold text-white">
|
|
142
|
+
로그인
|
|
143
|
+
</button>
|
|
144
|
+
</Form>
|
|
145
|
+
) : null}
|
|
146
|
+
{error ? <p className="text-point text-sm">{error}</p> : null}
|
|
147
|
+
{social.length ? (
|
|
148
|
+
<p className="text-center text-muted text-xs">처음 소셜 로그인하면 가입이 함께 돼요</p>
|
|
149
|
+
) : null}
|
|
71
150
|
</div>
|
|
72
151
|
);
|
|
73
152
|
}
|
package/template/app/routes.ts
CHANGED
|
@@ -12,6 +12,7 @@ export default [
|
|
|
12
12
|
route("checkout", "routes/checkout.tsx"),
|
|
13
13
|
route("checkout/complete", "routes/checkout-complete.tsx"),
|
|
14
14
|
route("login", "routes/login.tsx"),
|
|
15
|
+
route("auth/callback", "routes/auth-callback.tsx"),
|
|
15
16
|
route("orders", "routes/orders.tsx"),
|
|
16
17
|
route("orders/:orderId", "routes/order-detail.tsx"),
|
|
17
18
|
] satisfies RouteConfig;
|