Repository: saitamau-maximum/id
Pull request #166: fix: accept temporary users by using invitation code
Author: login: yukikamome316
Created: 2025-03-30T17:01:42Z | Merged: 2025-04-06T07:26:20Z

close #122 

Linked issue #122: [招待機能] 仮登録状態を持つ
ユーザーの状態を本登録と仮登録で分けたい　どう持つかは要検討
Linked issue comments: user: login: yukikamome316
created_at: 2025-03-28T13:18:08Z
body: @a01sa01to 

入会フローとしては、

1. Admin に招待リンクを受け取る
2. 招待リンクにアクセスすると、GitHub へのログインを求められるので認証する
3. 学籍番号などのプロフィールを入力する
4. 入力が完了すると仮登録状態となり、Admin から承認を受けるまで待機する (承認されるまで Dashboard などは閲覧不可)
5. 承認を受けたら本登録状態となり、一般ユーザーと同じ操作が出来るようになる

という流れで認識合っていますか？
author_association: MEMBER
user: login: a01sa01to
created_at: 2025-03-28T13:40:35Z
body: あってます！
強いて言えば、入力後じゃなくて GitHub 認証後に仮登録としてしまってもいいのかもしれない (このへんはやりやすいようにおまかせします！)
author_association: MEMBER
user: login: yukikamome316
created_at: 2025-03-28T14:22:36Z
body: わかりました！
author_association: MEMBER
user: login: yukikamome316
created_at: 2025-03-29T16:46:32Z
body: メモ:
`GET /invitation/:id` したら `Set-Cookie: invitation_id=:id` して `/login` にリダイレクト
GitHub OAuth からの `/fallback` で Cookie に invitation_id が存在すれば、招待コードを利用して `checkIsMember` を bypass
author_association: MEMBER
user: login: yukikamome316
created_at: 2025-03-29T20:22:33Z
body: @a01sa01to @sor4chi 

こんな感じの実装で大丈夫でしょうか...？

```mermaid
sequenceDiagram
  participant C as Client
  participant S as Server
  participant G as GitHub OAuth

  C->>C: GET /invitation/:id
  C->>S: GET /invite/:id
  S->>C: Set-Cookie: invitation_id=:id <br/> Redirect to /login
  C->>S: GET /auth/login/github
  S->>G: Redirect to GitHub OAuth
  G-->>S: OAuth 同意後、Redirect to /fallback
  S->>S: チェック: invitation_id が存在する場合、<br/>Orgs メンバーチェックを飛ばす
  S->>C: continue_to にリダイレクト
```

author_association: MEMBER
user: login: a01sa01to
created_at: 2025-03-30T02:19:50Z
body: よさそう～ (GH OAuth -> Server は `/callback` だけどわかるやろ)
細かい部分は PR 時に見るのでこのまま進めてもらって :pray:
author_association: MEMBER
user: login: yukikamome316
created_at: 2025-03-30T17:04:18Z
body: @a01sa01to @sor4chi 

一旦出来るところまで実装してみました。以下の Draft PR から方針が合っているかどうか確認お願いします！

- https://github.com/saitamau-maximum/id/pull/166
author_association: MEMBER

Diff:
diff --git a/workspaces/client/app/hooks/use-invitation/index.ts b/workspaces/client/app/hooks/use-invitation/index.ts
new file mode 100644
index 00000000..ffa3ca26
--- /dev/null
+++ b/workspaces/client/app/hooks/use-invitation/index.ts
@@ -0,0 +1,6 @@
+import { useContext } from "react";
+import { InvitationContext } from "./invitation-context";
+
+export const useInvitation = () => {
+	return useContext(InvitationContext);
+};
diff --git a/workspaces/client/app/hooks/use-invitation/invitation-context.tsx b/workspaces/client/app/hooks/use-invitation/invitation-context.tsx
new file mode 100644
index 00000000..473670da
--- /dev/null
+++ b/workspaces/client/app/hooks/use-invitation/invitation-context.tsx
@@ -0,0 +1,13 @@
+import { createContext } from "react";
+
+interface InvitationContextProps {
+	isInvited: boolean;
+	setIsInvited: (isInvited: boolean) => void;
+}
+
+export const InvitationContext = createContext<InvitationContextProps>({
+	isInvited: false,
+	setIsInvited: () => {
+		throw new Error("InvitationContext is not implemented");
+	},
+});
diff --git a/workspaces/client/app/hooks/use-invitation/invitation-provider.tsx b/workspaces/client/app/hooks/use-invitation/invitation-provider.tsx
new file mode 100644
index 00000000..7cf23124
--- /dev/null
+++ b/workspaces/client/app/hooks/use-invitation/invitation-provider.tsx
@@ -0,0 +1,21 @@
+import { useState } from "react";
+import { InvitationContext } from "./invitation-context";
+
+interface Props {
+	children: React.ReactNode;
+}
+
+export const InvitationProvider = ({ children }: Props) => {
+	const [isInvited, setIsInvited] = useState(false);
+
+	return (
+		<InvitationContext.Provider
+			value={{
+				isInvited,
+				setIsInvited,
+			}}
+		>
+			{children}
+		</InvitationContext.Provider>
+	);
+};
diff --git a/workspaces/client/app/repository/invitation.ts b/workspaces/client/app/repository/invitation.ts
index 8b1a59d1..40969a55 100644
--- a/workspaces/client/app/repository/invitation.ts
+++ b/workspaces/client/app/repository/invitation.ts
@@ -7,6 +7,10 @@ export interface GenerateInvitationOptions {
 	remainingUse: number | null;
 }
 
+export interface FetchInvitationParams {
+	invitationId: string;
+}
+
 export interface IInvitationRepository {
 	getInvitations: () => Promise<Invitation[]>;
 	getInvitations$$key: () => unknown[];
@@ -15,6 +19,8 @@ export interface IInvitationRepository {
 		expiresAt,
 		remainingUse,
 	}: GenerateInvitationOptions) => Promise<string>;
+	fetchInvitation: (params: FetchInvitationParams) => Promise<boolean>;
+	fetchInvitation$$key: (invitationId: string) => unknown[];
 }
 
 export class InvitationRepositoryImpl implements IInvitationRepository {
@@ -53,4 +59,27 @@ export class InvitationRepositoryImpl implements IInvitationRepository {
 		const data = await res.json();
 		return data.id;
 	}
+
+	async fetchInvitation(params: FetchInvitationParams): Promise<boolean> {
+		const res = await client.invite[":id"].$get(
+			{
+				param: {
+					id: params.invitationId,
+				},
+			},
+			{
+				init: {
+					credentials: "include",
+				},
+			},
+		);
+		if (!res.ok) {
+			throw new Error("Failed to fetch invitation");
+		}
+		return res.ok;
+	}
+
+	fetchInvitation$$key(invitationId: string): unknown[] {
+		return ["invitation", invitationId];
+	}
 }
diff --git a/workspaces/client/app/root.tsx b/workspaces/client/app/root.tsx
index 3eb3cda4..fc3773bf 100644
--- a/workspaces/client/app/root.tsx
+++ b/workspaces/client/app/root.tsx
@@ -15,6 +15,7 @@ import { RoleSelector } from "./components/feature/user/role-selector";
 import { UserSelector } from "./components/feature/user/user-selector";
 import { ConfirmDialog } from "./components/logic/callable/comfirm";
 import { InformationDialog } from "./components/logic/callable/information";
+import { InvitationProvider } from "./hooks/use-invitation/invitation-provider";
 import { RepositoryProvider } from "./hooks/use-repository";
 import { ToastProvider } from "./hooks/use-toast/toast-provider";
 import { initializeEnv } from "./utils/env";
@@ -90,11 +91,13 @@ export default function App() {
 		<RepositoryProvider>
 			<QueryClientProvider client={queryClient}>
 				<ToastProvider>
-					<Outlet />
-					<UserSelector.Root />
-					<RoleSelector.Root />
-					<ConfirmDialog.Root />
-					<InformationDialog.Root />
+					<InvitationProvider>
+						<Outlet />
+						<UserSelector.Root />
+						<RoleSelector.Root />
+						<ConfirmDialog.Root />
+						<InformationDialog.Root />
+					</InvitationProvider>
 				</ToastProvider>
 			</QueryClientProvider>
 		</RepositoryProvider>
diff --git a/workspaces/client/app/routes.ts b/workspaces/client/app/routes.ts
index 2427decb..8e584fad 100644
--- a/workspaces/client/app/routes.ts
+++ b/workspaces/client/app/routes.ts
@@ -52,6 +52,9 @@ export default [
 					])
 				: []),
 		]),
+		...(FLAG.ENABLE_INVITE
+			? prefix("invitation", [route(":id", "routes/invitation/page.tsx")])
+			: []),
 		route("onboarding", "routes/onboarding/page.tsx"),
 		route("verify", "routes/verify.tsx"),
 		route("login", "routes/login/page.tsx"),
diff --git a/workspaces/client/app/routes/dashboard/admin/users/internal/components/invitations-editor.tsx b/workspaces/client/app/routes/dashboard/admin/users/internal/components/invitations-editor.tsx
index a6d3c38f..5a94be9f 100644
--- a/workspaces/client/app/routes/dashboard/admin/users/internal/components/invitations-editor.tsx
+++ b/workspaces/client/app/routes/dashboard/admin/users/internal/components/invitations-editor.tsx
@@ -91,7 +91,7 @@ export const InvitationsEditor = () => {
 							>
 								<Table.Td>{invitation.title}</Table.Td>
 								<Table.Td>
-									{invitation.remainingUse && (
+									{invitation.remainingUse !== null && (
 										<div
 											className={css({
 												display: "flex",
diff --git a/workspaces/client/app/routes/invitation/page.tsx b/workspaces/client/app/routes/invitation/page.tsx
new file mode 100644
index 00000000..52c01fe4
--- /dev/null
+++ b/workspaces/client/app/routes/invitation/page.tsx
@@ -0,0 +1,54 @@
+import { useMutation } from "@tanstack/react-query";
+import { useEffect } from "react";
+import { useParams } from "react-router";
+import { useNavigate } from "react-router";
+import { useAuth } from "~/hooks/use-auth";
+import { useInvitation } from "~/hooks/use-invitation";
+import { useRepository } from "~/hooks/use-repository";
+import { useToast } from "~/hooks/use-toast";
+
+export default function Invitation() {
+	const { id } = useParams<{ id: string }>();
+	const navigate = useNavigate();
+	const { pushToast } = useToast();
+	const { invitationRepository } = useRepository();
+	const { isLoading, isAuthorized } = useAuth();
+	const { setIsInvited } = useInvitation();
+
+	const mutation = useMutation({
+		mutationFn: async (id: string) => {
+			const isSucceeded = await invitationRepository.fetchInvitation({
+				invitationId: id,
+			});
+			if (!isSucceeded) {
+				throw new Error("Failed to fetch invitation");
+			}
+		},
+		onSuccess: () => {
+			setIsInvited(true);
+			navigate("/login");
+		},
+		onError: () =>
+			pushToast({
+				type: "error",
+				title: "招待コードの取得に失敗しました",
+				description:
+					"時間をおいて再度お試しください。もし直らなければ Admin に連絡してください。",
+			}),
+	});
+
+	useEffect(() => {
+		// useAuth と useMutation の初期化が終わるまで何もしない (error 時も return)
+		if (isLoading || mutation.isPending || mutation.error) return;
+
+		// useParams の id が存在しない または 認証済み の場合は / にリダイレクト
+		if (!id || isAuthorized) {
+			navigate("/");
+			return;
+		}
+
+		mutation.mutate(id);
+	}, [isLoading, isAuthorized, id, navigate, mutation]);
+
+	return null;
+}
diff --git a/workspaces/client/app/routes/login/page.tsx b/workspaces/client/app/routes/login/page.tsx
index 3f8872d8..3f3e3cd7 100644
--- a/workspaces/client/app/routes/login/page.tsx
+++ b/workspaces/client/app/routes/login/page.tsx
@@ -1,8 +1,8 @@
 import { useEffect } from "react";
 import { useNavigate, useSearchParams } from "react-router";
 import { css } from "styled-system/css";
-import { AnchorLike } from "~/components/ui/anchor-like";
 import { useAuth } from "~/hooks/use-auth";
+import { useInvitation } from "~/hooks/use-invitation";
 import { useToast } from "~/hooks/use-toast";
 import { env } from "~/utils/env";
 import { FLAG } from "~/utils/flag";
@@ -13,6 +13,7 @@ export default function Login() {
 	const navigate = useNavigate();
 	const [searchParams] = useSearchParams();
 	const { pushToast } = useToast();
+	const { isInvited } = useInvitation();
 
 	const shouldProceed = !isLoading && isAuthorized;
 
@@ -91,15 +92,12 @@ export default function Login() {
 				Maximum IDPへようこそ！
 				<br />
 				埼玉大学のプログラミングサークル「Maximum」のプロフィール管理システムです
-				<br />
-				<a
-					href="https://github.com/saitamau-maximum"
-					className={css({ color: "blue.500" })}
-				>
-					<AnchorLike>Github Organization</AnchorLike>
-				</a>
-				に所属している方であれば <br />
-				どなたでもログイン可能です。
+				{isInvited && (
+					<p>
+						招待を受け入れるには以下のボタンから GitHub
+						アカウントでログインしてください。
+					</p>
+				)}
 			</p>
 			{FLAG.ENABLE_LOGIN ? (
 				<a
diff --git a/workspaces/server/drizzle/0022_useful_magma.sql b/workspaces/server/drizzle/0022_useful_magma.sql
new file mode 100644
index 00000000..8f117912
--- /dev/null
+++ b/workspaces/server/drizzle/0022_useful_magma.sql
@@ -0,0 +1 @@
+ALTER TABLE `users` ADD `invitation_id` text REFERENCES invites (id);--> statement-breakpoint
diff --git a/workspaces/server/drizzle/meta/0021_snapshot.json b/workspaces/server/drizzle/meta/0021_snapshot.json
index 49a3167f..779c8d33 100644
--- a/workspaces/server/drizzle/meta/0021_snapshot.json
+++ b/workspaces/server/drizzle/meta/0021_snapshot.json
@@ -1107,4 +1107,4 @@
   "internal": {
     "indexes": {}
   }
-}
\ No newline at end of file
+}
diff --git a/workspaces/server/drizzle/meta/0022_snapshot.json b/workspaces/server/drizzle/meta/0022_snapshot.json
new file mode 100644
index 00000000..64ab8589
--- /dev/null
+++ b/workspaces/server/drizzle/meta/0022_snapshot.json
@@ -0,0 +1,1131 @@
+{
+  "version": "6",
+  "dialect": "sqlite",
+  "id": "37be7446-f9b6-4347-8e01-afdc3214a02f",
+  "prevId": "bb5a241a-e219-474e-86f7-1a57e1f0d504",
+  "tables": {
+    "calendar_events": {
+      "name": "calendar_events",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "user_id": {
+          "name": "user_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "title": {
+          "name": "title",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "start_at": {
+          "name": "start_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "end_at": {
+          "name": "end_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "location_id": {
+          "name": "location_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "user_idx": {
+          "name": "user_idx",
+          "columns": [
+            "user_id"
+          ],
+          "isUnique": false
+        },
+        "start_at_idx": {
+          "name": "start_at_idx",
+          "columns": [
+            "start_at"
+          ],
+          "isUnique": false
+        },
+        "end_at_idx": {
+          "name": "end_at_idx",
+          "columns": [
+            "end_at"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {
+        "calendar_events_user_id_users_id_fk": {
+          "name": "calendar_events_user_id_users_id_fk",
+          "tableFrom": "calendar_events",
+          "tableTo": "users",
+          "columnsFrom": [
+            "user_id"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "no action",
+          "onUpdate": "no action"
+        },
+        "calendar_events_location_id_locations_id_fk": {
+          "name": "calendar_events_location_id_locations_id_fk",
+          "tableFrom": "calendar_events",
+          "tableTo": "locations",
+          "columnsFrom": [
+            "location_id"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "set null",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "certifications": {
+      "name": "certifications",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "title": {
+          "name": "title",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        }
+      },
+      "indexes": {
+        "certifications_title_unique": {
+          "name": "certifications_title_unique",
+          "columns": [
+            "title"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "invite_roles": {
+      "name": "invite_roles",
+      "columns": {
+        "invite_id": {
+          "name": "invite_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "role_id": {
+          "name": "role_id",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {
+        "invite_roles_invite_id_invites_id_fk": {
+          "name": "invite_roles_invite_id_invites_id_fk",
+          "tableFrom": "invite_roles",
+          "tableTo": "invites",
+          "columnsFrom": [
+            "invite_id"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "no action",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {
+        "invite_roles_invite_id_role_id_pk": {
+          "columns": [
+            "invite_id",
+            "role_id"
+          ],
+          "name": "invite_roles_invite_id_role_id_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "invites": {
+      "name": "invites",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "title": {
+          "name": "title",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "expires_at": {
+          "name": "expires_at",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "remaining_use": {
+          "name": "remaining_use",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "issued_by": {
+          "name": "issued_by",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {
+        "invites_issued_by_users_id_fk": {
+          "name": "invites_issued_by_users_id_fk",
+          "tableFrom": "invites",
+          "tableTo": "users",
+          "columnsFrom": [
+            "issued_by"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "no action",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "locations": {
+      "name": "locations",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "user_certifications": {
+      "name": "user_certifications",
+      "columns": {
+        "user_id": {
+          "name": "user_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "certification_id": {
+          "name": "certification_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "certified_in": {
+          "name": "certified_in",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "is_approved": {
+          "name": "is_approved",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": false
+        }
+      },
+      "indexes": {
+        "user_certifiedat_idx": {
+          "name": "user_certifiedat_idx",
+          "columns": [
+            "user_id",
+            "certified_in"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {
+        "user_certifications_user_id_users_id_fk": {
+          "name": "user_certifications_user_id_users_id_fk",
+          "tableFrom": "user_certifications",
+          "tableTo": "users",
+          "columnsFrom": [
+            "user_id"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "no action",
+          "onUpdate": "no action"
+        },
+        "user_certifications_certification_id_certifications_id_fk": {
+          "name": "user_certifications_certification_id_certifications_id_fk",
+          "tableFrom": "user_certifications",
+          "tableTo": "certifications",
+          "columnsFrom": [
+            "certification_id"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "no action",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {
+        "user_certifications_user_id_certification_id_pk": {
+          "columns": [
+            "user_id",
+            "certification_id"
+          ],
+          "name": "user_certifications_user_id_certification_id_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "user_profiles": {
+      "name": "user_profiles",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "user_id": {
+          "name": "user_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "display_name": {
+          "name": "display_name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "real_name": {
+          "name": "real_name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "real_name_kana": {
+          "name": "real_name_kana",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "display_id": {
+          "name": "display_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "profile_image_url": {
+          "name": "profile_image_url",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "academic_email": {
+          "name": "academic_email",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "email": {
+          "name": "email",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "student_id": {
+          "name": "student_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "grade": {
+          "name": "grade",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "bio": {
+          "name": "bio",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "grade_idx": {
+          "name": "grade_idx",
+          "columns": [
+            "grade"
+          ],
+          "isUnique": false
+        },
+        "display_id_unique": {
+          "name": "display_id_unique",
+          "columns": [
+            "display_id"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {
+        "user_profiles_user_id_users_id_fk": {
+          "name": "user_profiles_user_id_users_id_fk",
+          "tableFrom": "user_profiles",
+          "tableTo": "users",
+          "columnsFrom": [
+            "user_id"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "no action",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "user_roles": {
+      "name": "user_roles",
+      "columns": {
+        "user_id": {
+          "name": "user_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "role_id": {
+          "name": "role_id",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {
+        "user_roles_user_id_users_id_fk": {
+          "name": "user_roles_user_id_users_id_fk",
+          "tableFrom": "user_roles",
+          "tableTo": "users",
+          "columnsFrom": [
+            "user_id"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "no action",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {
+        "user_roles_user_id_role_id_pk": {
+          "columns": [
+            "user_id",
+            "role_id"
+          ],
+          "name": "user_roles_user_id_role_id_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "users": {
+      "name": "users",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "initialized_at": {
+          "name": "initialized_at",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "invitation_id": {
+          "name": "invitation_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {
+        "users_invitation_id_invites_id_fk": {
+          "name": "users_invitation_id_invites_id_fk",
+          "tableFrom": "users",
+          "tableTo": "invites",
+          "columnsFrom": [
+            "invitation_id"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "no action",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "oauth_client_callbacks": {
+      "name": "oauth_client_callbacks",
+      "columns": {
+        "client_id": {
+          "name": "client_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "callback_url": {
+          "name": "callback_url",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {
+        "oauth_client_callbacks_client_id_oauth_clients_id_fk": {
+          "name": "oauth_client_callbacks_client_id_oauth_clients_id_fk",
+          "tableFrom": "oauth_client_callbacks",
+          "tableTo": "oauth_clients",
+          "columnsFrom": [
+            "client_id"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "no action",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {
+        "oauth_client_callbacks_client_id_callback_url_pk": {
+          "columns": [
+            "client_id",
+            "callback_url"
+          ],
+          "name": "oauth_client_callbacks_client_id_callback_url_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "oauth_client_managers": {
+      "name": "oauth_client_managers",
+      "columns": {
+        "client_id": {
+          "name": "client_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "user_id": {
+          "name": "user_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {
+        "oauth_client_managers_client_id_oauth_clients_id_fk": {
+          "name": "oauth_client_managers_client_id_oauth_clients_id_fk",
+          "tableFrom": "oauth_client_managers",
+          "tableTo": "oauth_clients",
+          "columnsFrom": [
+            "client_id"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "no action",
+          "onUpdate": "no action"
+        },
+        "oauth_client_managers_user_id_users_id_fk": {
+          "name": "oauth_client_managers_user_id_users_id_fk",
+          "tableFrom": "oauth_client_managers",
+          "tableTo": "users",
+          "columnsFrom": [
+            "user_id"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "no action",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {
+        "oauth_client_managers_client_id_user_id_pk": {
+          "columns": [
+            "client_id",
+            "user_id"
+          ],
+          "name": "oauth_client_managers_client_id_user_id_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "oauth_client_scopes": {
+      "name": "oauth_client_scopes",
+      "columns": {
+        "client_id": {
+          "name": "client_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "scope_id": {
+          "name": "scope_id",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {
+        "oauth_client_scopes_client_id_oauth_clients_id_fk": {
+          "name": "oauth_client_scopes_client_id_oauth_clients_id_fk",
+          "tableFrom": "oauth_client_scopes",
+          "tableTo": "oauth_clients",
+          "columnsFrom": [
+            "client_id"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "no action",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {
+        "oauth_client_scopes_client_id_scope_id_pk": {
+          "columns": [
+            "client_id",
+            "scope_id"
+          ],
+          "name": "oauth_client_scopes_client_id_scope_id_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "oauth_client_secrets": {
+      "name": "oauth_client_secrets",
+      "columns": {
+        "client_id": {
+          "name": "client_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "secret": {
+          "name": "secret",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "issued_by": {
+          "name": "issued_by",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "issued_at": {
+          "name": "issued_at",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {
+        "oauth_client_secrets_client_id_oauth_clients_id_fk": {
+          "name": "oauth_client_secrets_client_id_oauth_clients_id_fk",
+          "tableFrom": "oauth_client_secrets",
+          "tableTo": "oauth_clients",
+          "columnsFrom": [
+            "client_id"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "no action",
+          "onUpdate": "no action"
+        },
+        "oauth_client_secrets_issued_by_users_id_fk": {
+          "name": "oauth_client_secrets_issued_by_users_id_fk",
+          "tableFrom": "oauth_client_secrets",
+          "tableTo": "users",
+          "columnsFrom": [
+            "issued_by"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "no action",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {
+        "oauth_client_secrets_client_id_secret_pk": {
+          "columns": [
+            "client_id",
+            "secret"
+          ],
+          "name": "oauth_client_secrets_client_id_secret_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "oauth_clients": {
+      "name": "oauth_clients",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "logo_url": {
+          "name": "logo_url",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "owner_id": {
+          "name": "owner_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {
+        "oauth_clients_owner_id_users_id_fk": {
+          "name": "oauth_clients_owner_id_users_id_fk",
+          "tableFrom": "oauth_clients",
+          "tableTo": "users",
+          "columnsFrom": [
+            "owner_id"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "no action",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "oauth_token_scopes": {
+      "name": "oauth_token_scopes",
+      "columns": {
+        "token_id": {
+          "name": "token_id",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "scope_id": {
+          "name": "scope_id",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {
+        "oauth_token_scopes_token_id_oauth_tokens_id_fk": {
+          "name": "oauth_token_scopes_token_id_oauth_tokens_id_fk",
+          "tableFrom": "oauth_token_scopes",
+          "tableTo": "oauth_tokens",
+          "columnsFrom": [
+            "token_id"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "no action",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {
+        "oauth_token_scopes_token_id_scope_id_pk": {
+          "columns": [
+            "token_id",
+            "scope_id"
+          ],
+          "name": "oauth_token_scopes_token_id_scope_id_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "oauth_tokens": {
+      "name": "oauth_tokens",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "integer",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": true
+        },
+        "client_id": {
+          "name": "client_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "user_id": {
+          "name": "user_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "code": {
+          "name": "code",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "code_expires_at": {
+          "name": "code_expires_at",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "code_used": {
+          "name": "code_used",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "redirect_uri": {
+          "name": "redirect_uri",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "access_token": {
+          "name": "access_token",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "access_token_expires_at": {
+          "name": "access_token_expires_at",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "oauth_tokens_code_unique": {
+          "name": "oauth_tokens_code_unique",
+          "columns": [
+            "code"
+          ],
+          "isUnique": true
+        },
+        "oauth_tokens_access_token_unique": {
+          "name": "oauth_tokens_access_token_unique",
+          "columns": [
+            "access_token"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {
+        "oauth_tokens_client_id_oauth_clients_id_fk": {
+          "name": "oauth_tokens_client_id_oauth_clients_id_fk",
+          "tableFrom": "oauth_tokens",
+          "tableTo": "oauth_clients",
+          "columnsFrom": [
+            "client_id"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "no action",
+          "onUpdate": "no action"
+        },
+        "oauth_tokens_user_id_users_id_fk": {
+          "name": "oauth_tokens_user_id_users_id_fk",
+          "tableFrom": "oauth_tokens",
+          "tableTo": "users",
+          "columnsFrom": [
+            "user_id"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "no action",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "oauth_connections": {
+      "name": "oauth_connections",
+      "columns": {
+        "user_id": {
+          "name": "user_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "provider_id": {
+          "name": "provider_id",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "provider_user_id": {
+          "name": "provider_user_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "email": {
+          "name": "email",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "profile_image_url": {
+          "name": "profile_image_url",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "oauth_connections_user_id_provider_id_pk": {
+          "columns": [
+            "user_id",
+            "provider_id"
+          ],
+          "name": "oauth_connections_user_id_provider_id_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    }
+  },
+  "views": {},
+  "enums": {},
+  "_meta": {
+    "schemas": {},
+    "tables": {},
+    "columns": {}
+  },
+  "internal": {
+    "indexes": {}
+  }
+}
diff --git a/workspaces/server/drizzle/meta/_journal.json b/workspaces/server/drizzle/meta/_journal.json
index 13978a2e..7b127dc7 100644
--- a/workspaces/server/drizzle/meta/_journal.json
+++ b/workspaces/server/drizzle/meta/_journal.json
@@ -155,6 +155,13 @@
       "when": 1743592465133,
       "tag": "0021_crazy_silk_fever",
       "breakpoints": true
+    },
+    {
+      "idx": 22,
+      "version": "6",
+      "when": 1743683970933,
+      "tag": "0022_useful_magma",
+      "breakpoints": true
     }
   ]
-}
\ No newline at end of file
+}
diff --git a/workspaces/server/src/constants/cookie.ts b/workspaces/server/src/constants/cookie.ts
index 8c26c078..88c03a37 100644
--- a/workspaces/server/src/constants/cookie.ts
+++ b/workspaces/server/src/constants/cookie.ts
@@ -2,4 +2,5 @@ export const COOKIE_NAME = {
 	CONTINUE_TO: "continue_to",
 	LOGIN_STATE: "login_state",
 	OAUTH_SESSION_STATE: "oauth_state",
+	INVITATION_ID: "invitation_id",
 };
diff --git a/workspaces/server/src/db/schema/app.ts b/workspaces/server/src/db/schema/app.ts
index 07a675c7..34d142ab 100644
--- a/workspaces/server/src/db/schema/app.ts
+++ b/workspaces/server/src/db/schema/app.ts
@@ -1,5 +1,6 @@
 import { relations } from "drizzle-orm";
 import {
+	type AnySQLiteColumn,
 	index,
 	int,
 	integer,
@@ -21,6 +22,10 @@ export const users = sqliteTable("users", {
 	id: text("id").primaryKey(),
 	/* 初期登録日時。NULLの場合は未初期化 */
 	initializedAt: integer("initialized_at", { mode: "timestamp" }),
+	/* 仮登録の場合は招待コードが初期化される。本登録が完了すると null となる。 */
+	invitationId: text("invitation_id").references(
+		(): AnySQLiteColumn => invites.id,
+	),
 });
 
 export const usersRelations = relations(users, ({ one, many }) => ({
@@ -28,6 +33,10 @@ export const usersRelations = relations(users, ({ one, many }) => ({
 		fields: [users.id],
 		references: [userProfiles.userId],
 	}),
+	invitation: one(invites, {
+		fields: [users.invitationId],
+		references: [invites.id],
+	}),
 	oauthOwningClients: many(oauthClients),
 	oauthManagingClients: many(oauthClients),
 	oauthIssuedSecrets: many(oauthClientSecrets),
diff --git a/workspaces/server/src/db/seed/senario/register-user.ts b/workspaces/server/src/db/seed/senario/register-user.ts
index 73b080f4..25f13c40 100644
--- a/workspaces/server/src/db/seed/senario/register-user.ts
+++ b/workspaces/server/src/db/seed/senario/register-user.ts
@@ -11,6 +11,20 @@ export const DUMMY_USER_IDS = {
 	USER5: "user5",
 };
 
+export const DUMMY_INVITATION = {
+	id: "deadbeef-0000-0000-0000",
+	title: "Dummy Invitation",
+	expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // expires in 7 days
+	remainingUse: 3,
+	createdAt: new Date(),
+	issuedByUserId: DUMMY_USER_IDS.USER1,
+};
+
+export const DUMMY_INVITE_ROLE = {
+	inviteId: DUMMY_INVITATION.id,
+	roleId: ROLE_IDS.MEMBER,
+};
+
 export const DUMMY_INITIALIZED_USERS = [
 	{
 		id: DUMMY_USER_IDS.USER1,
@@ -28,6 +42,7 @@ export const DUMMY_INITIALIZED_USERS = [
 			bio: "Hello, I'm User One!",
 		},
 		roles: [ROLE_IDS.ADMIN, ROLE_IDS.MEMBER],
+		invitationId: null,
 	},
 	{
 		id: DUMMY_USER_IDS.USER2,
@@ -45,6 +60,7 @@ export const DUMMY_INITIALIZED_USERS = [
 			bio: "Hello, I'm User Two!",
 		},
 		roles: [ROLE_IDS.MEMBER],
+		invitationId: DUMMY_INVITATION.id,
 	},
 	{
 		id: DUMMY_USER_IDS.USER3,
@@ -62,6 +78,7 @@ export const DUMMY_INITIALIZED_USERS = [
 			bio: "Hello, I'm User Three!",
 		},
 		roles: [ROLE_IDS.MEMBER],
+		invitationId: DUMMY_INVITATION.id,
 	},
 ];
 
@@ -74,6 +91,7 @@ export const DUMMY_UNINITIALIZED_USERS = [
 			displayName: "userfour",
 			email: "user4@example.com",
 		},
+		invitationId: DUMMY_INVITATION.id,
 	},
 	{
 		id: DUMMY_USER_IDS.USER5,
@@ -83,6 +101,7 @@ export const DUMMY_UNINITIALIZED_USERS = [
 			displayName: "userfive",
 			email: "user5@example.com",
 		},
+		invitationId: null,
 	},
 ];
 
@@ -128,12 +147,20 @@ export const DUMMY_OAUTH_CONNECTIONS = [
 export const registerUserSeed = async (
 	client: DrizzleD1Database<typeof schema>,
 ) => {
+	// invitation が user1 を参照しているので先に INSERT
+	await client.insert(schema.users).values(DUMMY_INITIALIZED_USERS[0]);
+	await client.insert(schema.invites).values(DUMMY_INVITATION);
+
 	for (const user of DUMMY_INITIALIZED_USERS) {
 		await client.batch([
-			client.insert(schema.users).values({
-				id: user.id,
-				initializedAt: new Date(),
-			}),
+			client
+				.insert(schema.users)
+				.values({
+					id: user.id,
+					initializedAt: new Date(),
+					invitationId: user.invitationId,
+				})
+				.onConflictDoNothing(), // user1 の重複 INSERT を無視する為の処理
 			client.insert(schema.userProfiles).values({
 				id: user.profile.id,
 				userId: user.id,
@@ -161,6 +188,7 @@ export const registerUserSeed = async (
 		await client.batch([
 			client.insert(schema.users).values({
 				id: user.id,
+				invitationId: user.invitationId,
 			}),
 			client.insert(schema.userProfiles).values({
 				id: user.profile.id,
@@ -181,4 +209,6 @@ export const registerUserSeed = async (
 			name: connection.login,
 		});
 	}
+
+	await client.insert(schema.inviteRoles).values(DUMMY_INVITE_ROLE);
 };
diff --git a/workspaces/server/src/db/seed/senario/reset.ts b/workspaces/server/src/db/seed/senario/reset.ts
index 447372aa..71aa1c2d 100644
--- a/workspaces/server/src/db/seed/senario/reset.ts
+++ b/workspaces/server/src/db/seed/senario/reset.ts
@@ -22,6 +22,15 @@ export const reset = async (client: DrizzleD1Database<typeof schema>) => {
 	await client.delete(schema.userCertifications);
 	await client.delete(schema.certifications);
 
+	// invite
+	// users.invitation_id が invites.id を参照しているので、
+	// users.invitation_id を NULL にしてから invites を削除する
+	await client.update(schema.users).set({
+		invitationId: null,
+	});
+	await client.delete(schema.inviteRoles);
+	await client.delete(schema.invites);
+
 	// app
 	await client.delete(schema.userProfiles);
 	await client.delete(schema.userRoles);
diff --git a/workspaces/server/src/infrastructure/repository/cloudflare/user.ts b/workspaces/server/src/infrastructure/repository/cloudflare/user.ts
index da120f18..18662cf0 100644
--- a/workspaces/server/src/infrastructure/repository/cloudflare/user.ts
+++ b/workspaces/server/src/infrastructure/repository/cloudflare/user.ts
@@ -18,33 +18,37 @@ export class CloudflareUserRepository implements IUserRepository {
 		this.client = drizzle(db, { schema });
 	}
 
-	async createUser(
+	private async createUserInternal(
 		providerUserId: string,
 		providerId: number,
-		payload: Partial<Profile> = {},
+		payload: Partial<Profile>,
+		invitationId?: string,
 	): Promise<string> {
 		const userId = crypto.randomUUID();
 
-		// d1にはtransactionがないのでbatchで両方成功を期待する
-		const [_, res] = await this.client.batch([
+		const { displayName, email, profileImageURL } = payload;
+		const batchOps = [
 			this.client.insert(schema.users).values({
 				id: userId,
+				...(invitationId && { invitationId }),
 			}),
 			this.client.insert(schema.oauthConnections).values({
 				userId,
 				providerId,
 				providerUserId,
-				email: payload.email,
-				name: payload.displayName,
-				profileImageUrl: payload.profileImageURL,
+				email,
+				name: displayName,
+				profileImageUrl: profileImageURL,
 			}),
 			this.client.insert(schema.userProfiles).values({
 				id: crypto.randomUUID(),
 				userId,
-				displayName: payload.displayName,
-				profileImageURL: payload.profileImageURL,
+				displayName,
+				profileImageURL,
 			}),
-		]);
+		] as const;
+
+		const [_, res] = await this.client.batch(batchOps);
 
 		if (res.success) {
 			return userId;
@@ -53,6 +57,28 @@ export class CloudflareUserRepository implements IUserRepository {
 		throw new Error("Failed to create user");
 	}
 
+	async createUser(
+		providerUserId: string,
+		providerId: number,
+		payload: Partial<Profile> = {},
+	): Promise<string> {
+		return this.createUserInternal(providerUserId, providerId, payload);
+	}
+
+	async createTemporaryUser(
+		providerUserId: string,
+		providerId: number,
+		invitationId: string,
+		payload: Partial<Profile> = {},
+	): Promise<string> {
+		return this.createUserInternal(
+			providerUserId,
+			providerId,
+			payload,
+			invitationId,
+		);
+	}
+
 	async fetchUserIdByProviderInfo(
 		providerUserId: string,
 		providerId: number,
diff --git a/workspaces/server/src/repository/user.ts b/workspaces/server/src/repository/user.ts
index def3bb97..7b3f7eed 100644
--- a/workspaces/server/src/repository/user.ts
+++ b/workspaces/server/src/repository/user.ts
@@ -59,6 +59,12 @@ export interface IUserRepository {
 		providerId: number,
 		payload: Partial<Profile>,
 	) => Promise<string>;
+	createTemporaryUser: (
+		providerUserId: string,
+		providerId: number,
+		invitationId: string,
+		payload: Partial<Profile>,
+	) => Promise<string>;
 	fetchUserIdByProviderInfo: (
 		providerUserId: string,
 		providerId: number,
diff --git a/workspaces/server/src/routes/auth-login/github.ts b/workspaces/server/src/routes/auth-login/github.ts
index ca7618bf..e8efc5fb 100644
--- a/workspaces/server/src/routes/auth-login/github.ts
+++ b/workspaces/server/src/routes/auth-login/github.ts
@@ -18,6 +18,7 @@ import { binaryToBase64 } from "../../utils/oauth/convert-bin-base64";
 const app = factory.createApp();
 
 const JWT_EXPIRATION = 60 * 60 * 24 * 7; // 1 week
+const INVITATION_ERROR_MESSAGE = "invalid invitation code";
 
 interface GitHubOAuthTokenResponse {
 	access_token: string;
@@ -123,34 +124,81 @@ const route = app
 			const userOctokit = new Octokit({ auth: access_token });
 			const { data: user } = await userOctokit.request("GET /user");
 
-			const isMember = await c.var.OrganizationRepository.checkIsMember(
-				user.login,
+			const invitationId = await getSignedCookie(
+				c,
+				c.env.SECRET,
+				COOKIE_NAME.INVITATION_ID,
 			);
+			deleteCookie(c, COOKIE_NAME.INVITATION_ID);
 
-			if (!isMember) {
-				// いったん Maximum Member じゃない場合はログインさせないようにする
-				return c.text("not a member", 403);
-			}
-
-			let foundUserId = null;
+			const githubUserIdStr = String(user.id);
+			let foundUserId: string | null;
 			try {
-				// ユーザーが存在するか確認
-				const id = await c.var.UserRepository.fetchUserIdByProviderInfo(
-					String(user.id),
-					OAUTH_PROVIDER_IDS.GITHUB,
-				);
-				foundUserId = id;
-			} catch (e) {
-				// もしユーザーが見つからなかったら新規作成
-				foundUserId = await c.var.UserRepository.createUser(
-					String(user.id),
+				// ユーザーの存在確認
+				foundUserId = await c.var.UserRepository.fetchUserIdByProviderInfo(
+					githubUserIdStr,
 					OAUTH_PROVIDER_IDS.GITHUB,
-					{
-						email: user.email ?? undefined,
-						displayName: user.login,
-						profileImageURL: user.avatar_url,
-					},
 				);
+			} catch {
+				// ユーザーが存在しなかった場合
+				if (typeof invitationId === "string") {
+					// 招待コードの署名検証に成功しているので、コードを検証する
+					try {
+						const invitation =
+							await c.var.InviteRepository.getInviteById(invitationId);
+						if (!invitation) return c.text(INVITATION_ERROR_MESSAGE, 400);
+
+						// 利用可能回数の検証
+						if (
+							invitation.remainingUse !== null &&
+							invitation.remainingUse <= 0
+						)
+							return c.text(INVITATION_ERROR_MESSAGE, 400);
+
+						// 有効期限の検証
+						if (
+							invitation.expiresAt !== null &&
+							invitation.expiresAt < new Date()
+						)
+							return c.text(INVITATION_ERROR_MESSAGE, 400);
+
+						// 招待コードが有効な場合は消費する
+						await c.var.InviteRepository.reduceInviteUsage(invitationId);
+					} catch {
+						return c.text(INVITATION_ERROR_MESSAGE, 400);
+					}
+					// 招待コードが有効な場合、仮登録処理を行う
+					foundUserId = await c.var.UserRepository.createTemporaryUser(
+						githubUserIdStr,
+						OAUTH_PROVIDER_IDS.GITHUB,
+						invitationId,
+						{
+							email: user.email ?? undefined,
+							displayName: user.login,
+							profileImageURL: user.avatar_url,
+						},
+					);
+				} else if (invitationId === undefined) {
+					// 招待コードが提供されなかった場合、GitHub Organization メンバーかどうかを確認する処理
+					const isMember = await c.var.OrganizationRepository.checkIsMember(
+						user.login,
+					);
+					if (!isMember)
+						return c.text("invitation code required for non-members", 403);
+					// Organization のメンバーであれば、本登録処理を行う
+					foundUserId = await c.var.UserRepository.createUser(
+						githubUserIdStr,
+						OAUTH_PROVIDER_IDS.GITHUB,
+						{
+							email: user.email ?? undefined,
+							displayName: user.login,
+							profileImageURL: user.avatar_url,
+						},
+					);
+				} else {
+					// invitationId が不正な場合の処理
+					return c.text(INVITATION_ERROR_MESSAGE, 400);
+				}
 			}
 
 			const now = Math.floor(Date.now() / 1000);
diff --git a/workspaces/server/src/routes/invite.ts b/workspaces/server/src/routes/invite.ts
index a9b198bd..0e995153 100644
--- a/workspaces/server/src/routes/invite.ts
+++ b/workspaces/server/src/routes/invite.ts
@@ -1,5 +1,9 @@
 import { vValidator } from "@hono/valibot-validator";
+import { setSignedCookie } from "hono/cookie";
+import { cors } from "hono/cors";
+import type { CookieOptions } from "hono/utils/cookie";
 import * as v from "valibot";
+import { COOKIE_NAME } from "../constants/cookie";
 import { ROLE_IDS } from "../constants/role";
 import { factory } from "../factory";
 import {
@@ -9,13 +13,46 @@ import {
 
 const app = factory.createApp();
 
+const INVITATION_COOKIE_EXP = 60 * 60 * 24; // 1 day
+
 const createInviteSchema = v.object({
 	title: v.pipe(v.string(), v.nonEmpty(), v.maxLength(64)),
 	expiresAt: v.optional(v.pipe(v.string(), v.isoTimestamp())),
 	remainingUse: v.optional(v.pipe(v.number(), v.minValue(1))),
 });
 
-const route = app
+const getCookieOptions = (isLocal: boolean): CookieOptions => ({
+	path: "/",
+	secure: !isLocal,
+	sameSite: "lax", // "strict" にすると OAuth の callback を経由した際に読み取れなくなる
+	httpOnly: true,
+	maxAge: INVITATION_COOKIE_EXP,
+});
+
+// dev 環境における c.env.ALLOW_ORIGIN は "*" であるため Set-Cookie を受け入れられない
+// そのため CORS の設定には c.env.CLIENT_ORIGIN を用いる
+const publicRoute = app
+	.use((c, next) => {
+		return cors({
+			origin: c.env.CLIENT_ORIGIN,
+			credentials: true,
+		})(c, next);
+	})
+	.get("/:id", async (c) => {
+		const id = c.req.param("id");
+		const requestUrl = new URL(c.req.url);
+		setSignedCookie(
+			c,
+			COOKIE_NAME.INVITATION_ID,
+			id,
+			c.env.SECRET,
+			getCookieOptions(requestUrl.protocol === "http:"),
+		);
+
+		return c.json({ success: true });
+	});
+
+const protectedRoute = app
 	.use(authMiddleware)
 	.use(
 		roleAuthorizationMiddleware({
@@ -94,4 +131,5 @@ const route = app
 		}
 	});
 
+const route = app.route("/", publicRoute).route("/", protectedRoute);
 export { route as inviteRoute };


Reviews:
user: login: a01sa01to
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-31T03:05:23Z
user: login: yukikamome316
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-31T03:27:42Z
user: login: a01sa01to
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-31T03:33:58Z
user: login: a01sa01to
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-31T03:34:46Z
user: login: yukikamome316
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-31T03:35:49Z
user: login: yukikamome316
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-31T03:37:30Z
user: login: a01sa01to
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-31T03:45:37Z
user: login: sor4chi
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-31T04:43:59Z
user: login: yukikamome316
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-01T08:14:54Z
user: login: a01sa01to
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-01T08:17:05Z
user: login: yukikamome316
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-01T08:24:44Z
user: login: a01sa01to
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-01T08:26:09Z
user: login: a01sa01to
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-01T09:00:59Z
user: login: a01sa01to
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-01T12:46:58Z
user: login: yukikamome316
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-01T12:50:15Z
user: login: a01sa01to
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-01T12:52:28Z
user: login: yukikamome316
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-01T15:42:06Z
user: login: a01sa01to
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-02T00:29:24Z
user: login: a01sa01to
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-02T00:30:17Z
user: login: a01sa01to
body: #167 で migration 更新されたので更新してほしいです！
(conflict がたぶんそれでなおりそう)
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-02T12:35:26Z
user: login: yukikamome316
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-03T09:12:04Z
user: login: a01sa01to
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-03T09:26:12Z
user: login: yukikamome316
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-03T09:27:24Z
user: login: yukikamome316
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-03T09:32:08Z
user: login: a01sa01to
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-03T09:39:46Z
user: login: yukikamome316
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-03T09:44:39Z
user: login: batora9
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T16:18:40Z
user: login: a01sa01to
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-04T14:17:48Z
user: login: a01sa01to
body: ありがとう、お疲れ様〜！
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-05T00:13:10Z
user: login: sor4chi
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-04-05T02:37:34Z
user: login: yukikamome316
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-05T07:54:41Z
user: login: yukikamome316
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-05T07:55:29Z
user: login: yukikamome316
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-05T15:11:45Z
user: login: sor4chi
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-05T15:47:18Z
user: login: sor4chi
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-05T15:48:06Z
user: login: yukikamome316
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-05T15:49:20Z
user: login: a01sa01to
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-06T04:33:54Z
user: login: a01sa01to
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-06T05:25:29Z
user: login: a01sa01to
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-06T05:37:42Z
user: login: a01sa01to
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-06T06:16:04Z
user: login: a01sa01to
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-06T07:05:36Z
user: login: a01sa01to
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-06T07:11:30Z
user: login: a01sa01to
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-06T07:18:12Z
user: login: a01sa01to
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-06T07:20:37Z
user: login: sor4chi
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-06T07:25:25Z

Review comments:
diff_hunk: @@ -0,0 +1,34 @@
+import { useEffect } from "react";
+import { useParams } from "react-router";
+import { useNavigate } from "react-router";
+import { env } from "~/utils/env";
+
+export default function Invitation() {
+	const { id } = useParams<{ id: string }>();
+	const navigate = useNavigate();
+
+	useEffect(() => {
+		const fetchInvitation = async () => {
+			try {
+				const response = await fetch(`${env("SERVER_HOST")}/invite/${id}`, {
+					credentials: "include",
+				});
+				if (!response.ok) {
+					throw new Error("Failed to fetch invitation");
+				}
path: workspaces/client/app/routes/invitation/page.tsx
user: login: a01sa01to
body: error になったら toast かなにかで表示させてあげたい気はする
created_at: 2025-03-31T02:58:24Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,2 @@
+ALTER TABLE `users` ADD `is_pending` integer NOT NULL;--> statement-breakpoint
path: workspaces/server/drizzle/0021_busy_black_bolt.sql
user: login: a01sa01to
body: migration 落ちそうな気がするな (杞憂かも)
created_at: 2025-03-31T02:59:57Z
author_association: MEMBER
diff_hunk: @@ -123,13 +123,32 @@ const route = app
 			const userOctokit = new Octokit({ auth: access_token });
 			const { data: user } = await userOctokit.request("GET /user");
 
-			const isMember = await c.var.OrganizationRepository.checkIsMember(
-				user.login,
-			);
-
-			if (!isMember) {
-				// いったん Maximum Member じゃない場合はログインさせないようにする
-				return c.text("not a member", 403);
+			const invitationId = getCookie(c, COOKIE_NAME.INVITATION_ID);
+			deleteCookie(c, COOKIE_NAME.INVITATION_ID);
+
+			if (invitationId) {
+				// 招待コードがある場合は、招待コードを検証する
+				try {
+					const invitation =
+						await c.var.InviteRepository.getInviteById(invitationId);
+					if (
+						!invitation ||
+						(invitation.remainingUse !== null && invitation.remainingUse <= 0)
path: workspaces/server/src/routes/auth-login/github.ts
user: login: a01sa01to
body: 有効期限もチェックしてほしいかも (もうすでに機能としてはできているので)
created_at: 2025-03-31T03:01:46Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,2 @@
+ALTER TABLE `users` ADD `is_pending` integer NOT NULL;--> statement-breakpoint
path: workspaces/server/drizzle/0021_busy_black_bolt.sql
user: login: yukikamome316
body: 自分の環境だと成功していますが、落ちるとしたらどんな感じですか？
created_at: 2025-03-31T03:27:42Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,2 @@
+ALTER TABLE `users` ADD `is_pending` integer NOT NULL;--> statement-breakpoint
path: workspaces/server/drizzle/0021_busy_black_bolt.sql
user: login: a01sa01to
body: is_pending が not null になってるので non-null constraint とか落ちそうだなー
とか思ったけどデフォルトで 0 になるのかな？ だったら大丈夫そうなんだけど
created_at: 2025-03-31T03:33:58Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,2 @@
+ALTER TABLE `users` ADD `is_pending` integer NOT NULL;--> statement-breakpoint
path: workspaces/server/drizzle/0021_busy_black_bolt.sql
user: login: a01sa01to
body: 試してみたらエラー出た、この通りです

![image](https://github.com/user-attachments/assets/3857bf53-11e8-4760-8390-08b6a9d7ce05)

created_at: 2025-03-31T03:34:46Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,2 @@
+ALTER TABLE `users` ADD `is_pending` integer NOT NULL;--> statement-breakpoint
path: workspaces/server/drizzle/0021_busy_black_bolt.sql
user: login: yukikamome316
body: あー、そしたらスキーマの方で明示的にデフォルト値設定しますね
created_at: 2025-03-31T03:35:49Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,2 @@
+ALTER TABLE `users` ADD `is_pending` integer NOT NULL;--> statement-breakpoint
path: workspaces/server/drizzle/0021_busy_black_bolt.sql
user: login: yukikamome316
body: is_pending のデフォルトは 0 よりも 1 の方がいいですかね？
created_at: 2025-03-31T03:37:30Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,2 @@
+ALTER TABLE `users` ADD `is_pending` integer NOT NULL;--> statement-breakpoint
path: workspaces/server/drizzle/0021_busy_black_bolt.sql
user: login: a01sa01to
body: それ悩ましいところで、 1 にしちゃうと今いるメンバー全員が `is_pending` 判定食らっちゃうんよね
とはいえ 0 だと何らかの理由で bypass できちゃったら怖いという

なのでいったん 0 にして migration した後に、 non-null つける or default 1 とかがよさそうな気がしてます
それを実現する方法として、

- 21.sql では default 0 にして 22.sql で non-null / default 1 とする
- 手書きで 21.sql で alter default 0 にした後に non-null / default 1 を追加する

とかがありそう
created_at: 2025-03-31T03:45:36Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,2 @@
+ALTER TABLE `users` ADD `is_pending` integer NOT NULL;--> statement-breakpoint
path: workspaces/server/drizzle/0021_busy_black_bolt.sql
user: login: sor4chi
body: 訳: defaultないと既存ユーザーのADD COLMUNができないよ
created_at: 2025-03-31T04:43:59Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,2 @@
+ALTER TABLE `users` ADD `is_pending` integer NOT NULL;--> statement-breakpoint
path: workspaces/server/drizzle/0021_busy_black_bolt.sql
user: login: yukikamome316
body: ```sql
ALTER TABLE `users` ADD `is_pending` integer DEFAULT 0;--> statement-breakpoint
ALTER TABLE `users` MODIFY `is_pending` integer NOT NULL DEFAULT 1;
```

こんな感じでどうです？
created_at: 2025-04-01T08:14:54Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,2 @@
+ALTER TABLE `users` ADD `is_pending` integer NOT NULL;--> statement-breakpoint
path: workspaces/server/drizzle/0021_busy_black_bolt.sql
user: login: a01sa01to
body: 最初の部分は default ついてるから not null でもいい気がする？
created_at: 2025-04-01T08:17:04Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,2 @@
+ALTER TABLE `users` ADD `is_pending` integer NOT NULL;--> statement-breakpoint
path: workspaces/server/drizzle/0021_busy_black_bolt.sql
user: login: yukikamome316
body: わかりました
workspaces/server/src/db/schema/app.ts のスキーマ定義では
```ts
isPending: integer("is_pending", { mode: "boolean" }).notNull().default(true)
```
としておきますか？
created_at: 2025-04-01T08:24:44Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,2 @@
+ALTER TABLE `users` ADD `is_pending` integer NOT NULL;--> statement-breakpoint
path: workspaces/server/drizzle/0021_busy_black_bolt.sql
user: login: a01sa01to
body: よさそう
created_at: 2025-04-01T08:26:09Z
author_association: MEMBER
diff_hunk: @@ -1,2 +1,4 @@
-ALTER TABLE `users` ADD `is_pending` integer NOT NULL;--> statement-breakpoint
-ALTER TABLE `users` ADD `invitation_id` text;
\ No newline at end of file
+ALTER TABLE `users` ADD `is_pending` integer NOT NULL DEFAULT 0;--> statement-breakpoint
+ALTER TABLE `users` MODIFY `is_pending` integer NOT NULL DEFAULT 1;
path: workspaces/server/drizzle/0021_busy_black_bolt.sql
user: login: a01sa01to
body: ```suggestion
ALTER TABLE `users` MODIFY `is_pending` integer NOT NULL DEFAULT 1;--> statement-breakpoint
```
たぶんこうするのが適切なんじゃないかな
created_at: 2025-04-01T08:58:32Z
author_association: MEMBER
diff_hunk: @@ -14,15 +16,25 @@ export default function Invitation() {
 					credentials: "include",
 				});
 				if (!response.ok) {
+					pushToast({
+						type: "error",
+						title:
+							"招待コードの取得に失敗しました。時間をおいて再度お試しください。",
+					});
 					throw new Error("Failed to fetch invitation");
 				}
 				navigate("/login");
 			} catch (error) {
+				pushToast({
+					type: "error",
+					title:
+						"招待コードの取得に失敗しました。時間をおいて再度お試しください。",
+				});
path: workspaces/client/app/routes/invitation/page.tsx
user: login: a01sa01to
body: これ throw されてるから 2 重で toast 出てくる気がする？
created_at: 2025-04-01T08:59:23Z
author_association: MEMBER
diff_hunk: @@ -14,15 +16,25 @@ export default function Invitation() {
 					credentials: "include",
 				});
 				if (!response.ok) {
+					pushToast({
+						type: "error",
+						title:
+							"招待コードの取得に失敗しました。時間をおいて再度お試しください。",
+					});
 					throw new Error("Failed to fetch invitation");
 				}
 				navigate("/login");
 			} catch (error) {
+				pushToast({
+					type: "error",
+					title:
+						"招待コードの取得に失敗しました。時間をおいて再度お試しください。",
path: workspaces/client/app/routes/invitation/page.tsx
user: login: a01sa01to
body: title: 招待コードの取得に失敗しました
description: 時間をおいて再度お試しください。もし直らなければ Admin に連絡してください。

とするのがよさそう
created_at: 2025-04-01T09:00:03Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,37 @@
+import { useEffect } from "react";
+import { useParams } from "react-router";
+import { useNavigate } from "react-router";
+import { useToast } from "~/hooks/use-toast";
+import { env } from "~/utils/env";
+
+export default function Invitation() {
+	const { id } = useParams<{ id: string }>();
+	const navigate = useNavigate();
+	const { pushToast } = useToast();
+
+	useEffect(() => {
+		const fetchInvitation = async () => {
+			try {
+				const response = await fetch(`${env("SERVER_HOST")}/invite/${id}`, {
+					credentials: "include",
+				});
+				if (!response.ok) {
+					throw new Error("Failed to fetch invitation");
+				}
+				setTimeout(() => {
+					navigate("/login");
+				}, 1800);
path: workspaces/client/app/routes/invitation/page.tsx
user: login: a01sa01to
body: timeout いる？
created_at: 2025-04-01T12:38:33Z
author_association: MEMBER
diff_hunk: @@ -9,13 +13,43 @@ import {
 
 const app = factory.createApp();
 
+const INVITATION_COOKIE_EXP = 60 * 60 * 24; // 1 day
+
 const createInviteSchema = v.object({
 	title: v.pipe(v.string(), v.nonEmpty(), v.maxLength(64)),
 	expiresAt: v.optional(v.pipe(v.string(), v.isoTimestamp())),
 	remainingUse: v.optional(v.pipe(v.number(), v.minValue(1))),
 });
 
+const getCookieOptions = (isLocal: boolean): CookieOptions => ({
+	path: "/",
+	secure: !isLocal,
+	sameSite: "lax", // "strict" にすると OAuth の callback を経由した際に読み取れなくなる
+	httpOnly: true,
+	maxAge: INVITATION_COOKIE_EXP,
+});
+
 const route = app
+	.use((c, next) => {
+		return cors({
+			origin: c.env.CLIENT_ORIGIN, // ALLOW_ORIGIN を用いると dev 環境で Cookie をセットできなくなる
+			credentials: true,
+		})(c, next);
+	})
+	.get("/:id", async (c) => {
+		const id = c.req.param("id");
+		const requestUrl = new URL(c.req.url);
+		setCookie(
path: workspaces/server/src/routes/invite.ts
user: login: a01sa01to
body: 念のため signedCookie がいい気がする
env に `SECRET` があるのでそれを使ってもらって
created_at: 2025-04-01T12:41:10Z
author_association: MEMBER
diff_hunk: @@ -123,13 +124,48 @@ const route = app
 			const userOctokit = new Octokit({ auth: access_token });
 			const { data: user } = await userOctokit.request("GET /user");
 
-			const isMember = await c.var.OrganizationRepository.checkIsMember(
-				user.login,
-			);
-
-			if (!isMember) {
-				// いったん Maximum Member じゃない場合はログインさせないようにする
-				return c.text("not a member", 403);
+			const invitationId = getCookie(c, COOKIE_NAME.INVITATION_ID);
+			deleteCookie(c, COOKIE_NAME.INVITATION_ID);
+
+			const isValidInvitation = (invitation: InviteStructure): boolean => {
+				if (!invitation) return false;
+
+				// 利用可能回数の検証
+				if (invitation.remainingUse !== null && invitation.remainingUse <= 0)
+					return false;
+				// 有効期限の検証
+				if (invitation.expiresAt !== null && invitation.expiresAt < new Date())
+					return false;
+
+				return true;
+			};
path: workspaces/server/src/routes/auth-login/github.ts
user: login: a01sa01to
body: 逆に複雑になってそうなので consumeInvitation 内部に組み込んじゃっていい気がするな
created_at: 2025-04-01T12:44:19Z
author_association: MEMBER
diff_hunk: @@ -21,13 +21,20 @@ export const users = sqliteTable("users", {
 	id: text("id").primaryKey(),
 	/* 初期登録日時。NULLの場合は未初期化 */
 	initializedAt: integer("initialized_at", { mode: "timestamp" }),
+	isPending: integer("is_pending", { mode: "boolean" }).notNull().default(true),
+	/* 招待コード経由で登録した場合に初期化される */
+	invitationId: text("invitation_id"),
path: workspaces/server/src/db/schema/app.ts
user: login: a01sa01to
body: これだったら `invitationId` だけにして `isPending` は「`invitationId` が null かどうか」で判定できそうな気がするんだけどどうなんだろ...
created_at: 2025-04-01T12:46:39Z
author_association: MEMBER
diff_hunk: @@ -21,13 +21,20 @@ export const users = sqliteTable("users", {
 	id: text("id").primaryKey(),
 	/* 初期登録日時。NULLの場合は未初期化 */
 	initializedAt: integer("initialized_at", { mode: "timestamp" }),
+	isPending: integer("is_pending", { mode: "boolean" }).notNull().default(true),
+	/* 招待コード経由で登録した場合に初期化される */
+	invitationId: text("invitation_id"),
path: workspaces/server/src/db/schema/app.ts
user: login: yukikamome316
body: Admin からの承認を受けて本登録完了になる認識でしたので、`isPending`, `invitationId` の両方を持たせています。
created_at: 2025-04-01T12:50:15Z
author_association: MEMBER
diff_hunk: @@ -21,13 +21,20 @@ export const users = sqliteTable("users", {
 	id: text("id").primaryKey(),
 	/* 初期登録日時。NULLの場合は未初期化 */
 	initializedAt: integer("initialized_at", { mode: "timestamp" }),
+	isPending: integer("is_pending", { mode: "boolean" }).notNull().default(true),
+	/* 招待コード経由で登録した場合に初期化される */
+	invitationId: text("invitation_id"),
path: workspaces/server/src/db/schema/app.ts
user: login: a01sa01to
body: ここ仕様がまだ曖昧な部分あるんだけど、本登録完了しても `invitationId` は残るかんじにしておく？
(個人的にはなくてもいいやろとか思ってるんだけど、あったらうれしい？)
created_at: 2025-04-01T12:52:28Z
author_association: MEMBER
diff_hunk: @@ -21,13 +21,20 @@ export const users = sqliteTable("users", {
 	id: text("id").primaryKey(),
 	/* 初期登録日時。NULLの場合は未初期化 */
 	initializedAt: integer("initialized_at", { mode: "timestamp" }),
+	isPending: integer("is_pending", { mode: "boolean" }).notNull().default(true),
+	/* 招待コード経由で登録した場合に初期化される */
+	invitationId: text("invitation_id"),
path: workspaces/server/src/db/schema/app.ts
user: login: yukikamome316
body: Discord だと `invitationId` みたいなものはユーザーの DB に残っていた気がしますね
ただ、自分もあんまりこだわりはないです...
created_at: 2025-04-01T15:42:06Z
author_association: MEMBER
diff_hunk: @@ -21,13 +21,20 @@ export const users = sqliteTable("users", {
 	id: text("id").primaryKey(),
 	/* 初期登録日時。NULLの場合は未初期化 */
 	initializedAt: integer("initialized_at", { mode: "timestamp" }),
+	isPending: integer("is_pending", { mode: "boolean" }).notNull().default(true),
+	/* 招待コード経由で登録した場合に初期化される */
+	invitationId: text("invitation_id"),
path: workspaces/server/src/db/schema/app.ts
user: login: a01sa01to
body: どうせあっても使わん気がするんだよな...
考えられる用途が moderation log とかしか考えられないが、もしやるなら log 用の別 table に書くべきな気が
(ほかの用途何かあるかな)

is_pending 消すと https://github.com/saitamau-maximum/id/pull/166#issuecomment-2769266121 解決できてうれしい...？
created_at: 2025-04-02T00:29:24Z
author_association: MEMBER
diff_hunk: @@ -21,13 +21,20 @@ export const users = sqliteTable("users", {
 	id: text("id").primaryKey(),
 	/* 初期登録日時。NULLの場合は未初期化 */
 	initializedAt: integer("initialized_at", { mode: "timestamp" }),
+	isPending: integer("is_pending", { mode: "boolean" }).notNull().default(true),
+	/* 招待コード経由で登録した場合に初期化される */
+	invitationId: text("invitation_id"),
path: workspaces/server/src/db/schema/app.ts
user: login: a01sa01to
body: そういえば reference つけたほうがいいかも？
created_at: 2025-04-02T00:30:11Z
author_association: MEMBER
diff_hunk: @@ -53,4 +59,20 @@ export class InvitationRepositoryImpl implements IInvitationRepository {
 		const data = await res.json();
 		return data.id;
 	}
+
+	async fetchInvitation(params: FetchInvitationParams): Promise<boolean> {
+		const res = await client.invite[":id"].$get({
+			param: {
+				id: params.invitationId,
+			},
+		});
+		if (!res.ok) {
+			throw new Error("Failed to fetch invitation");
+		}
+		return res.ok;
+	}
+
+	fetchInvitation$$key(): unknown[] {
+		return ["invitation"];
path: workspaces/client/app/repository/invitation.ts
user: login: a01sa01to
body: 見落としてた、これは invitation id も配列に含めるべきじゃないかな？
(別の invitation id を fetch しようとしたときにも同じ値がキャッシュとして使われてしまうので)

`locationRepo.getLocationById$$key` とかが参考になりそう
created_at: 2025-04-02T12:34:02Z
author_association: MEMBER
diff_hunk: @@ -21,13 +21,20 @@ export const users = sqliteTable("users", {
 	id: text("id").primaryKey(),
 	/* 初期登録日時。NULLの場合は未初期化 */
 	initializedAt: integer("initialized_at", { mode: "timestamp" }),
+	isPending: integer("is_pending", { mode: "boolean" }).notNull().default(true),
+	/* 招待コード経由で登録した場合に初期化される */
+	invitationId: text("invitation_id"),
path: workspaces/server/src/db/schema/app.ts
user: login: yukikamome316
body: <img width="1100" alt="スクリーンショット 2025-04-03 18 11 13" src="https://github.com/user-attachments/assets/2a82245c-98de-4ebd-910d-ed4ee91b2234" />

ここで reference つけると型エラーになってしまうんですよね...
どうすればいいでしょうか？
created_at: 2025-04-03T09:12:04Z
author_association: MEMBER
diff_hunk: @@ -21,13 +21,20 @@ export const users = sqliteTable("users", {
 	id: text("id").primaryKey(),
 	/* 初期登録日時。NULLの場合は未初期化 */
 	initializedAt: integer("initialized_at", { mode: "timestamp" }),
+	isPending: integer("is_pending", { mode: "boolean" }).notNull().default(true),
+	/* 招待コード経由で登録した場合に初期化される */
+	invitationId: text("invitation_id"),
path: workspaces/server/src/db/schema/app.ts
user: login: a01sa01to
body: あー、循環参照してるからか
https://github.com/drizzle-team/drizzle-orm/issues/2476 これっぽい、 `AnySQLiteColumn` 型つけてやるといいらしい
created_at: 2025-04-03T09:26:12Z
author_association: MEMBER
diff_hunk: @@ -21,13 +21,20 @@ export const users = sqliteTable("users", {
 	id: text("id").primaryKey(),
 	/* 初期登録日時。NULLの場合は未初期化 */
 	initializedAt: integer("initialized_at", { mode: "timestamp" }),
+	isPending: integer("is_pending", { mode: "boolean" }).notNull().default(true),
+	/* 招待コード経由で登録した場合に初期化される */
+	invitationId: text("invitation_id"),
path: workspaces/server/src/db/schema/app.ts
user: login: yukikamome316
body: そうです
あーーまさにこれが知りたかったです。ありがとうございます！
created_at: 2025-04-03T09:27:24Z
author_association: MEMBER
diff_hunk: @@ -21,13 +21,20 @@ export const users = sqliteTable("users", {
 	id: text("id").primaryKey(),
 	/* 初期登録日時。NULLの場合は未初期化 */
 	initializedAt: integer("initialized_at", { mode: "timestamp" }),
+	isPending: integer("is_pending", { mode: "boolean" }).notNull().default(true),
+	/* 招待コード経由で登録した場合に初期化される */
+	invitationId: text("invitation_id"),
path: workspaces/server/src/db/schema/app.ts
user: login: yukikamome316
body: @batora9 @sor4chi 

この件どっちがいいですか？
とりあえず https://github.com/saitamau-maximum/id/pull/166#issuecomment-2769266121 はすでに解決しています。
created_at: 2025-04-03T09:32:08Z
author_association: MEMBER
diff_hunk: @@ -23,7 +24,12 @@ export const users = sqliteTable("users", {
 	initializedAt: integer("initialized_at", { mode: "timestamp" }),
 	isPending: integer("is_pending", { mode: "boolean" }).notNull().default(true),
 	/* 招待コード経由で登録した場合に初期化される */
-	invitationId: text("invitation_id"),
+	invitationId: text("invitation_id").references(
+		(): AnySQLiteColumn => invites.id,
+		{
+			onDelete: "set null",
path: workspaces/server/src/db/schema/app.ts
user: login: a01sa01to
body: no action のまま (消そうとしたらエラー) がよさそう
消そうとしている招待リンクを使った仮登録ユーザーがいた場合、 null になって isPending: true + invitationId: null になって後々 (admin approve 周り) 困りそう
created_at: 2025-04-03T09:39:46Z
author_association: MEMBER
diff_hunk: @@ -23,7 +24,12 @@ export const users = sqliteTable("users", {
 	initializedAt: integer("initialized_at", { mode: "timestamp" }),
 	isPending: integer("is_pending", { mode: "boolean" }).notNull().default(true),
 	/* 招待コード経由で登録した場合に初期化される */
-	invitationId: text("invitation_id"),
+	invitationId: text("invitation_id").references(
+		(): AnySQLiteColumn => invites.id,
+		{
+			onDelete: "set null",
path: workspaces/server/src/db/schema/app.ts
user: login: yukikamome316
body: 確かにそうですね
created_at: 2025-04-03T09:44:39Z
author_association: MEMBER
diff_hunk: @@ -21,13 +21,20 @@ export const users = sqliteTable("users", {
 	id: text("id").primaryKey(),
 	/* 初期登録日時。NULLの場合は未初期化 */
 	initializedAt: integer("initialized_at", { mode: "timestamp" }),
+	isPending: integer("is_pending", { mode: "boolean" }).notNull().default(true),
+	/* 招待コード経由で登録した場合に初期化される */
+	invitationId: text("invitation_id"),
path: workspaces/server/src/db/schema/app.ts
user: login: batora9
body: `isPending`があった方がわかりやすいかなと
created_at: 2025-04-03T16:18:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -21,13 +21,20 @@ export const users = sqliteTable("users", {
 	id: text("id").primaryKey(),
 	/* 初期登録日時。NULLの場合は未初期化 */
 	initializedAt: integer("initialized_at", { mode: "timestamp" }),
+	isPending: integer("is_pending", { mode: "boolean" }).notNull().default(true),
+	/* 招待コード経由で登録した場合に初期化される */
+	invitationId: text("invitation_id"),
path: workspaces/server/src/db/schema/app.ts
user: login: a01sa01to
body: https://discord.com/channels/1036566020070060032/1188775377137569843/1357719221202583784 (このへん)
消す方向で行きましょう～ @yukikamome316 
created_at: 2025-04-04T14:17:48Z
author_association: MEMBER
diff_hunk: @@ -21,15 +21,19 @@ export class CloudflareUserRepository implements IUserRepository {
 	async createUser(
path: workspaces/server/src/infrastructure/repository/cloudflare/user.ts
user: login: sor4chi
body: ここinvitateionIdがoptionalなのが気になるから分けるって話、忘れてる気がする
created_at: 2025-04-05T02:17:26Z
author_association: MEMBER
diff_hunk: @@ -9,13 +13,44 @@ import {
 
 const app = factory.createApp();
 
+const INVITATION_COOKIE_EXP = 60 * 60 * 24; // 1 day
+
 const createInviteSchema = v.object({
 	title: v.pipe(v.string(), v.nonEmpty(), v.maxLength(64)),
 	expiresAt: v.optional(v.pipe(v.string(), v.isoTimestamp())),
 	remainingUse: v.optional(v.pipe(v.number(), v.minValue(1))),
 });
 
+const getCookieOptions = (isLocal: boolean): CookieOptions => ({
+	path: "/",
+	secure: !isLocal,
+	sameSite: "lax", // "strict" にすると OAuth の callback を経由した際に読み取れなくなる
+	httpOnly: true,
+	maxAge: INVITATION_COOKIE_EXP,
+});
+
 const route = app
+	.use((c, next) => {
+		return cors({
+			origin: c.env.CLIENT_ORIGIN, // ALLOW_ORIGIN を用いると dev 環境で Cookie をセットできなくなる
path: workspaces/server/src/routes/invite.ts
user: login: sor4chi
body: 事実ではなく理由を書いてほしい！
created_at: 2025-04-05T02:19:06Z
author_association: MEMBER
diff_hunk: @@ -123,13 +123,53 @@ const route = app
 			const userOctokit = new Octokit({ auth: access_token });
 			const { data: user } = await userOctokit.request("GET /user");
 
-			const isMember = await c.var.OrganizationRepository.checkIsMember(
-				user.login,
+			const invitationId = await getSignedCookie(
+				c,
+				c.env.SECRET,
+				COOKIE_NAME.INVITATION_ID,
 			);
-
-			if (!isMember) {
-				// いったん Maximum Member じゃない場合はログインさせないようにする
-				return c.text("not a member", 403);
+			deleteCookie(c, COOKIE_NAME.INVITATION_ID);
+
+			const consumeInvitation = async (
path: workspaces/server/src/routes/auth-login/github.ts
user: login: sor4chi
body: ハンドラ上で1回しか呼ばれてない処理を関数化してるのは理由がある？
created_at: 2025-04-05T02:21:24Z
author_association: MEMBER
diff_hunk: @@ -94,4 +129,4 @@ const route = app
 		}
 	});
 
-export { route as inviteRoute };
+export { route as inviteRoute, protectedInviteRoute };
path: workspaces/server/src/routes/invite.ts
user: login: sor4chi
body: src/index.tsでは同じパスへのregisterが1つだけになるようにしたい
やるなら
```ts
const publicRoute = ...
const protectedRoute = ...

const route = app.route("/", publicRoute).route("/", protectedRoute)
export { route as inviteRoute };
```
こうしてほしいかな
created_at: 2025-04-05T02:23:37Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,45 @@
+import { useQuery } from "@tanstack/react-query";
+import { useEffect } from "react";
+import { useParams } from "react-router";
+import { useNavigate } from "react-router";
+import { useRepository } from "~/hooks/use-repository";
+import { useToast } from "~/hooks/use-toast";
+import type { FetchInvitationParams } from "~/repository/invitation";
+
+export default function Invitation() {
+	const { id } = useParams<{ id: string }>();
+	const navigate = useNavigate();
+	const { pushToast } = useToast();
+	const { invitationRepository } = useRepository();
+
+	if (!id) {
+		navigate("/login");
+		return null;
+	}
+
+	const { isLoading, error } = useQuery({
+		queryKey: invitationRepository.fetchInvitation$$key(id),
+		queryFn: async () => {
+			const params: FetchInvitationParams = { invitationId: id };
+			return invitationRepository.fetchInvitation(params);
+		},
path: workspaces/client/app/routes/invitation/page.tsx
user: login: sor4chi
body: ```suggestion
		queryFn: () => invitationRepository.fetchInvitation({ invitationId: id }),
```
これじゃだめかい？
created_at: 2025-04-05T02:35:02Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,45 @@
+import { useQuery } from "@tanstack/react-query";
+import { useEffect } from "react";
+import { useParams } from "react-router";
+import { useNavigate } from "react-router";
+import { useRepository } from "~/hooks/use-repository";
+import { useToast } from "~/hooks/use-toast";
+import type { FetchInvitationParams } from "~/repository/invitation";
+
+export default function Invitation() {
+	const { id } = useParams<{ id: string }>();
+	const navigate = useNavigate();
+	const { pushToast } = useToast();
+	const { invitationRepository } = useRepository();
+
+	if (!id) {
+		navigate("/login");
+		return null;
+	}
+
+	const { isLoading, error } = useQuery({
path: workspaces/client/app/routes/invitation/page.tsx
user: login: sor4chi
body: これ、useMutationにするとeffectとか使わないので綺麗になるよ
created_at: 2025-04-05T02:36:45Z
author_association: MEMBER
diff_hunk: @@ -123,13 +123,53 @@ const route = app
 			const userOctokit = new Octokit({ auth: access_token });
 			const { data: user } = await userOctokit.request("GET /user");
 
-			const isMember = await c.var.OrganizationRepository.checkIsMember(
-				user.login,
+			const invitationId = await getSignedCookie(
+				c,
+				c.env.SECRET,
+				COOKIE_NAME.INVITATION_ID,
 			);
-
-			if (!isMember) {
-				// いったん Maximum Member じゃない場合はログインさせないようにする
-				return c.text("not a member", 403);
+			deleteCookie(c, COOKIE_NAME.INVITATION_ID);
+
+			const consumeInvitation = async (
path: workspaces/server/src/routes/auth-login/github.ts
user: login: yukikamome316
body: 最初はそうしていたんですが、可読性が低い気がしたので切り分けました
created_at: 2025-04-05T07:54:41Z
author_association: MEMBER
diff_hunk: @@ -21,15 +21,19 @@ export class CloudflareUserRepository implements IUserRepository {
 	async createUser(
path: workspaces/server/src/infrastructure/repository/cloudflare/user.ts
user: login: yukikamome316
body: 忘れてました、修正します
created_at: 2025-04-05T07:55:28Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,45 @@
+import { useQuery } from "@tanstack/react-query";
+import { useEffect } from "react";
+import { useParams } from "react-router";
+import { useNavigate } from "react-router";
+import { useRepository } from "~/hooks/use-repository";
+import { useToast } from "~/hooks/use-toast";
+import type { FetchInvitationParams } from "~/repository/invitation";
+
+export default function Invitation() {
+	const { id } = useParams<{ id: string }>();
+	const navigate = useNavigate();
+	const { pushToast } = useToast();
+	const { invitationRepository } = useRepository();
+
+	if (!id) {
+		navigate("/login");
+		return null;
+	}
+
+	const { isLoading, error } = useQuery({
path: workspaces/client/app/routes/invitation/page.tsx
user: login: yukikamome316
body: こんな感じでしょうか？

```ts
import { useMutation } from "@tanstack/react-query";
import { useParams } from "react-router";
import { useNavigate } from "react-router";
import { useRepository } from "~/hooks/use-repository";
import { useToast } from "~/hooks/use-toast";

export default function Invitation() {
	const { id } = useParams<{ id: string }>();
	const navigate = useNavigate();
	const { pushToast } = useToast();
	const { invitationRepository } = useRepository();

	if (!id) {
		navigate("/login");
		return;
	}

	useMutation({
		mutationFn: async () => {
			const isSucceeded = invitationRepository.fetchInvitation({
				invitationId: id,
			});
			if (!isSucceeded) {
				throw new Error("Failed to fetch invitation");
			}
		},
		onSuccess: () => {
			navigate("/login");
		},
		onError: () => {
			pushToast({
				type: "error",
				title: "招待コードの取得に失敗しました",
				description:
					"時間をおいて再度お試しください。もし直らなければ Admin に連絡してください。",
			});
		},
	}).mutate();

	return null;
}
```
created_at: 2025-04-05T15:11:45Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,45 @@
+import { useQuery } from "@tanstack/react-query";
+import { useEffect } from "react";
+import { useParams } from "react-router";
+import { useNavigate } from "react-router";
+import { useRepository } from "~/hooks/use-repository";
+import { useToast } from "~/hooks/use-toast";
+import type { FetchInvitationParams } from "~/repository/invitation";
+
+export default function Invitation() {
+	const { id } = useParams<{ id: string }>();
+	const navigate = useNavigate();
+	const { pushToast } = useToast();
+	const { invitationRepository } = useRepository();
+
+	if (!id) {
+		navigate("/login");
+		return null;
+	}
+
+	const { isLoading, error } = useQuery({
path: workspaces/client/app/routes/invitation/page.tsx
user: login: sor4chi
body: そうね
他のところでやってるみたいにcustom hookで切り出すと良さそう
effect結局使ってて申し訳ないんだけどmutateはuseEffect内で呼んで
created_at: 2025-04-05T15:47:18Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,45 @@
+import { useQuery } from "@tanstack/react-query";
+import { useEffect } from "react";
+import { useParams } from "react-router";
+import { useNavigate } from "react-router";
+import { useRepository } from "~/hooks/use-repository";
+import { useToast } from "~/hooks/use-toast";
+import type { FetchInvitationParams } from "~/repository/invitation";
+
+export default function Invitation() {
+	const { id } = useParams<{ id: string }>();
+	const navigate = useNavigate();
+	const { pushToast } = useToast();
+	const { invitationRepository } = useRepository();
+
+	if (!id) {
+		navigate("/login");
+		return null;
+	}
+
+	const { isLoading, error } = useQuery({
path: workspaces/client/app/routes/invitation/page.tsx
user: login: sor4chi
body: あと基本的にはhook(custom hook)の前にreturnするのだめなので気をつけて
created_at: 2025-04-05T15:48:06Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,45 @@
+import { useQuery } from "@tanstack/react-query";
+import { useEffect } from "react";
+import { useParams } from "react-router";
+import { useNavigate } from "react-router";
+import { useRepository } from "~/hooks/use-repository";
+import { useToast } from "~/hooks/use-toast";
+import type { FetchInvitationParams } from "~/repository/invitation";
+
+export default function Invitation() {
+	const { id } = useParams<{ id: string }>();
+	const navigate = useNavigate();
+	const { pushToast } = useToast();
+	const { invitationRepository } = useRepository();
+
+	if (!id) {
+		navigate("/login");
+		return null;
+	}
+
+	const { isLoading, error } = useQuery({
path: workspaces/client/app/routes/invitation/page.tsx
user: login: yukikamome316
body: 了解です！
created_at: 2025-04-05T15:49:20Z
author_association: MEMBER
diff_hunk: @@ -34,6 +33,11 @@ export default function Invitation() {
 	});
 
 	useEffect(() => {
+		// 既にログインしている場合に /invite に Preflight request が発生しないようにする
+		if (localStorage.getItem(JWT_STORAGE_KEY)) {
+			localStorage.removeItem(JWT_STORAGE_KEY);
+		}
path: workspaces/client/app/routes/invitation/page.tsx
user: login: a01sa01to
body: ほかの部分で再ログイン必要になっちゃわないか？
それよりかは `useAuth` して `isAuthorized` とかで判定するのが良さげな気がしている
created_at: 2025-04-06T04:33:54Z
author_association: MEMBER
diff_hunk: @@ -33,19 +37,30 @@ export default function Invitation() {
 	});
 
 	useEffect(() => {
-		// 既にログインしている場合に /invite に Preflight request が発生しないようにする
-		if (localStorage.getItem(JWT_STORAGE_KEY)) {
-			localStorage.removeItem(JWT_STORAGE_KEY);
-		}
+		// useAuth と useMutation の初期化が終わるまで何もしない (error 時も return)
+		if (isLoading || mutation.isPending || mutation.error) return;
 
-		if (!id) {
+		// useParams の id が存在しないか、認証済みの場合は /login にリダイレクト
+		if (!id || authorized) {
 			navigate("/login");
 			return;
 		}
 
-		if (mutation.isPending || mutation.error) return;
+		// 認証済みかつ初期登録が済んでいない場合は /onboarding にリダイレクト
+		if (shouldOnboarding) {
+			navigate("/onboarding");
+			return;
+		}
path: workspaces/client/app/routes/invitation/page.tsx
user: login: a01sa01to
body: 一律で `/` に飛ばせばほかの部分でよしなにしてくれそうな気がする？
created_at: 2025-04-06T05:25:29Z
author_association: MEMBER
diff_hunk: @@ -123,13 +124,43 @@ const route = app
 			const userOctokit = new Octokit({ auth: access_token });
 			const { data: user } = await userOctokit.request("GET /user");
 
-			const isMember = await c.var.OrganizationRepository.checkIsMember(
-				user.login,
+			const invitationId = await getSignedCookie(
+				c,
+				c.env.SECRET,
+				COOKIE_NAME.INVITATION_ID,
 			);
-
-			if (!isMember) {
-				// いったん Maximum Member じゃない場合はログインさせないようにする
-				return c.text("not a member", 403);
+			deleteCookie(c, COOKIE_NAME.INVITATION_ID);
+
+			if (typeof invitationId === "string") {
+				// 招待コードの署名検証に成功しているので、コードを検証する
+				try {
+					const invitation =
+						await c.var.InviteRepository.getInviteById(invitationId);
+					if (!invitation) return c.text(INVITATION_ERROR_MESSAGE, 400);
+					// 利用可能回数の検証
+					if (invitation.remainingUse !== null && invitation.remainingUse <= 0)
+						return c.text(INVITATION_ERROR_MESSAGE, 400);
+					// 有効期限の検証
+					if (
+						invitation.expiresAt !== null &&
+						invitation.expiresAt < new Date()
+					)
+						return c.text(INVITATION_ERROR_MESSAGE, 400);
+					await c.var.InviteRepository.reduceInviteUsage(invitationId);
+				} catch {
+					return c.text(INVITATION_ERROR_MESSAGE, 400);
+				}
+			} else if (invitationId === undefined) {
+				// 招待コードが存在しない場合、Organization のメンバーかどうかを確認する
+				const isMember = await c.var.OrganizationRepository.checkIsMember(
+					user.login,
+				);
+				if (!isMember) {
+					return c.text("invitation code required for non-members", 403);
+				}
+			} else {
+				// invitationId が false など、署名検証に失敗している場合
+				return c.text(INVITATION_ERROR_MESSAGE, 400);
path: workspaces/server/src/routes/auth-login/github.ts
user: login: a01sa01to
body: 🔈 このへん 
created_at: 2025-04-06T05:37:42Z
author_association: MEMBER
diff_hunk: @@ -131,52 +131,45 @@ const route = app
 			);
 			deleteCookie(c, COOKIE_NAME.INVITATION_ID);
 
-			if (typeof invitationId === "string") {
-				// 招待コードの署名検証に成功しているので、コードを検証する
-				try {
-					const invitation =
-						await c.var.InviteRepository.getInviteById(invitationId);
-					if (!invitation) return c.text(INVITATION_ERROR_MESSAGE, 400);
-					// 利用可能回数の検証
-					if (invitation.remainingUse !== null && invitation.remainingUse <= 0)
-						return c.text(INVITATION_ERROR_MESSAGE, 400);
-					// 有効期限の検証
-					if (
-						invitation.expiresAt !== null &&
-						invitation.expiresAt < new Date()
-					)
-						return c.text(INVITATION_ERROR_MESSAGE, 400);
-					await c.var.InviteRepository.reduceInviteUsage(invitationId);
-				} catch {
-					return c.text(INVITATION_ERROR_MESSAGE, 400);
-				}
-			} else if (invitationId === undefined) {
-				// 招待コードが存在しない場合、Organization のメンバーかどうかを確認する
-				const isMember = await c.var.OrganizationRepository.checkIsMember(
-					user.login,
-				);
-				if (!isMember) {
-					return c.text("invitation code required for non-members", 403);
-				}
-			} else {
-				// invitationId が false など、署名検証に失敗している場合
-				return c.text(INVITATION_ERROR_MESSAGE, 400);
-			}
-
-			let foundUserId = null;
+			const userIdStr = String(user.id);
path: workspaces/server/src/routes/auth-login/github.ts
user: login: a01sa01to
body: たぶん処理自体はよさそう
GitHub の uid であることを変数名なりで分かりやすくできるとなお良いかも
created_at: 2025-04-06T06:15:47Z
author_association: MEMBER
diff_hunk: @@ -44,15 +44,10 @@ export default function Invitation() {
 		// useAuth と useMutation の初期化が終わるまで何もしない (error 時も return)
 		if (isLoading || mutation.isPending || mutation.error) return;
 
-		// useParams の id が存在しないか、認証済みの場合は /login にリダイレクト
-		if (!id || authorized) {
-			navigate("/login");
-			return;
-		}
-
-		// 認証済みかつ初期登録が済んでいない場合は /onboarding にリダイレクト
-		if (shouldOnboarding) {
-			navigate("/onboarding");
+		// useParams の id が存在しない　 または 認証済み
+		// または 認証済みかつ初期登録が済んでいない場合は / にリダイレクト
+		if (!id || authorized || shouldOnboarding) {
path: workspaces/client/app/routes/invitation/page.tsx
user: login: a01sa01to
body: ```suggestion
		if (!id || isAuthorized) {
```
これでいい気がするな

created_at: 2025-04-06T07:05:36Z
author_association: MEMBER
diff_hunk: @@ -44,15 +44,10 @@ export default function Invitation() {
 		// useAuth と useMutation の初期化が終わるまで何もしない (error 時も return)
 		if (isLoading || mutation.isPending || mutation.error) return;
 
-		// useParams の id が存在しないか、認証済みの場合は /login にリダイレクト
-		if (!id || authorized) {
-			navigate("/login");
-			return;
-		}
-
-		// 認証済みかつ初期登録が済んでいない場合は /onboarding にリダイレクト
-		if (shouldOnboarding) {
-			navigate("/onboarding");
+		// useParams の id が存在しない　 または 認証済み
+		// または 認証済みかつ初期登録が済んでいない場合は / にリダイレクト
+		if (!id || authorized || shouldOnboarding) {
path: workspaces/client/app/routes/invitation/page.tsx
user: login: a01sa01to
body: `authorized = !isLoading && isAuthorized` もいらなさそう @yukikamome316 
created_at: 2025-04-06T07:11:30Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,55 @@
+import { useMutation } from "@tanstack/react-query";
+import { useEffect } from "react";
+import { useParams } from "react-router";
+import { useNavigate } from "react-router";
+import { useAuth } from "~/hooks/use-auth";
+import { useInvitation } from "~/hooks/use-invitation";
+import { useRepository } from "~/hooks/use-repository";
+import { useToast } from "~/hooks/use-toast";
+
+export default function Invitation() {
+	const { id } = useParams<{ id: string }>();
+	const navigate = useNavigate();
+	const { pushToast } = useToast();
+	const { invitationRepository } = useRepository();
+	const { isLoading, isAuthorized } = useAuth();
+	const { setIsInvited } = useInvitation();
+
+	const mutation = useMutation({
+		mutationFn: async (id: string) => {
+			const isSucceeded = await invitationRepository.fetchInvitation({
+				invitationId: id,
+			});
+			if (!isSucceeded) {
+				throw new Error("Failed to fetch invitation");
+			}
+		},
+		onSuccess: () => {
+			setIsInvited(true);
+			navigate("/login");
+		},
+		onError: () =>
+			pushToast({
+				type: "error",
+				title: "招待コードの取得に失敗しました",
+				description:
+					"時間をおいて再度お試しください。もし直らなければ Admin に連絡してください。",
+			}),
+	});
+
+	useEffect(() => {
+		// useAuth と useMutation の初期化が終わるまで何もしない (error 時も return)
+		if (isLoading || mutation.isPending || mutation.error) return;
+
+		// useParams の id が存在しない　 または 認証済み
+		// または 認証済みかつ初期登録が済んでいない場合は / にリダイレクト
path: workspaces/client/app/routes/invitation/page.tsx
user: login: a01sa01to
body: ```suggestion
		// useParams の id が存在しない または 認証済み の場合は / にリダイレクト
```
created_at: 2025-04-06T07:18:11Z
author_association: MEMBER

PR comments:
user: login: a01sa01to
created_at: 2025-03-31T03:10:55Z
body: あ、方針としてはだいじょうぶそうです
author_association: MEMBER
user: login: yukikamome316
created_at: 2025-03-31T03:27:59Z
body: > あ、方針としてはだいじょうぶそうです

ご確認ありがとうございます！
author_association: MEMBER
user: login: cloudflare-workers-and-pages[bot]
created_at: 2025-04-01T08:55:16Z
body: ## Deploying id with &nbsp;<a href="https://pages.dev"><img alt="Cloudflare Pages" src="https://user-images.githubusercontent.com/23264/106598434-9e719e00-654f-11eb-9e59-6167043cfa01.png" width="16"></a> &nbsp;Cloudflare Pages

<table><tr><td><strong>Latest commit:</strong> </td><td>
<code>7a38b39</code>
</td></tr>
<tr><td><strong>Status:</strong></td><td>&nbsp;✅&nbsp; Deploy successful!</td></tr>
<tr><td><strong>Preview URL:</strong></td><td>
<a href='https://cbf576c9.id-131.pages.dev'>https://cbf576c9.id-131.pages.dev</a>
</td></tr>
<tr><td><strong>Branch Preview URL:</strong></td><td>
<a href='https://feat-temp-user.id-131.pages.dev'>https://feat-temp-user.id-131.pages.dev</a>
</td></tr>
</table>

[View logs](https://dash.cloudflare.com/338e04f1067dcfb64f701ebc7c5041a0/pages/view/id/cbf576c9-e3c3-4b42-b008-13ff70912560)

author_association: NONE
user: login: batora9
created_at: 2025-04-01T12:54:30Z
body: @yukikamome316 
```
[ERROR] near "MODIFY": syntax error at offset 20: SQLITE_ERROR
```
📝マイグレーション時にエラーが出る
author_association: CONTRIBUTOR
user: login: yukikamome316
created_at: 2025-04-01T15:39:48Z
body: @a01sa01to 
https://github.com/saitamau-maximum/id/blob/c580109c24de207b49a97189ed7ee6eb98c42d74/workspaces/client/app/routes/invitation/page.tsx#L15-L23

ここで GET /invite/:id しているのですが、クライアント側で Set-Cookie を受け入れるにはどうすればいいでしょうか...？
このままだと Cookie が設定されない状況です。
author_association: MEMBER
user: login: sor4chi
created_at: 2025-04-02T00:11:01Z
body: @yukikamome316 
https://github.com/saitamau-maximum/id/blob/c580109c24de207b49a97189ed7ee6eb98c42d74/workspaces/client/app/repository/invitation.ts#L63-L73
ここにcredential option刺せるはず

https://hono.dev/docs/guides/rpc

ただのfetchのwrapperなので
author_association: MEMBER
user: login: a01sa01to
created_at: 2025-04-02T00:14:23Z
body: https://zenn.dev/sakamoto1997/articles/11df3d06183560
この辺参考にできそう...？

あるいはもう処理自体変えちゃうというのも手かも (以下はあくまで一例)

1. client にアクセス
2. server にリダイレクトして validation + set cookie
3. client login にリダイレクト
author_association: MEMBER
user: login: yukikamome316
created_at: 2025-04-03T10:15:17Z
body: @a01sa01to 

> #167 で migration 更新されたので更新してほしいです！

この場合の正しいコンフリクト解消方法を教えてください。

21.sql を 22 に rename して色々辻褄合わせても、workspaces/server/drizzle/meta/_journal.json の `when` が昇順にならなくなってしまい困っています。
22.sql を自動生成させて先に書いていた migration の処理を載せる感じが自然ですかね？
author_association: MEMBER
user: login: a01sa01to
created_at: 2025-04-03T10:37:44Z
body: > 22.sql を自動生成させて先に書いていた migration の処理を載せる感じが自然ですかね？

たぶんこっちのほうが典型的で自然なんじゃないかな？

ちなみに調べたらこんなのでてきた: https://github.com/drizzle-team/drizzle-orm/discussions/1104
Merge queue なんてあるのか...
author_association: MEMBER
user: login: yukikamome316
created_at: 2025-04-03T10:55:14Z
body: >> 22.sql を自動生成させて先に書いていた migration の処理を載せる感じが自然ですかね？

> たぶんこっちのほうが典型的で自然なんじゃないかな？

わかりました！
 
> ちなみに調べたらこんなのでてきた: [drizzle-team/drizzle-orm#1104](https://github.com/drizzle-team/drizzle-orm/discussions/1104) Merge queue なんてあるのか...

やるとしたら、前述した「 22.sql を自動生成させて先に書いていた migration の処理を載せる」という処理を、main に rebase した際に自動的にやってくれるようにする workflow を書く感じですかね？
あんまりよくわかっていないです...
author_association: MEMBER
user: login: a01sa01to
created_at: 2025-04-03T12:19:52Z
body: Workflow 書いて、 Merge Queue に突っ込む～的なの押したら main と PR をいいかんじに Merge して migration file も生成してから main に push みたいにできるのかな？
自分もあまりよく分かっておらず... (そこまで大規模開発じゃないのでまあ使わんでも...とは思うけど)
author_association: MEMBER
user: login: a01sa01to
created_at: 2025-04-03T14:47:03Z
body: :memo: コードは OK、意見待ち https://github.com/saitamau-maximum/id/pull/166#discussion_r2026588771
何も返信なければ明日夕方このままマージ
author_association: MEMBER
user: login: sor4chi
created_at: 2025-04-05T02:45:30Z
body: これ自分のローカル環境では`http://localhost:8787/invite/:id`へのリクエストがcredential-includeなのにAllow-Originが*だよってCORSエラー出るんだけどなんかする必要ある？
author_association: MEMBER
user: login: yukikamome316
created_at: 2025-04-05T15:09:29Z
body: @sor4chi (@a01sa01to)

> これ自分のローカル環境では`http://localhost:8787/invite/:id`へのリクエストがcredential-includeなのにAllow-Originが*だよってCORSエラー出るんだけどなんかする必要ある？

あれ？本当ですね
preflight で `Access-Controll-Allow-Origin: *` になっています

![image](https://github.com/user-attachments/assets/ee2f245a-086e-4579-be93-1320104a42d0)

/invite の CORS は `c.env.CLIENT_ORIGIN (http://localhost:5173)` を許可していて、動作確認が取れた時から一度も設定を変更していません。

PR 見返してみましたが原因を特定できませんでした。何かアドバイスいただきたいです。
author_association: MEMBER
user: login: a01sa01to
created_at: 2025-04-05T15:19:41Z
body: これ自分の環境だと何も出なかったなぁ　というかそもそも preflight 飛んでないんだが
author_association: MEMBER
user: login: a01sa01to
created_at: 2025-04-05T15:23:03Z
body: や、ログイン状態だと preflight が飛んでダメっぽい？
ログイン状態だと `authorization` を付加する -> simple request にならない -> preflight 飛ぶ　みたいなかんじかな

https://github.com/honojs/hono/blob/882e75a3e00e98997177aaaef60bbf9b1b4eb3bf/src/middleware/cors/index.ts#L113-L142 の特に L123-128 あたりっぽい
author_association: MEMBER
user: login: sor4chi
created_at: 2025-04-05T15:45:43Z
body: なるほど
ログイン時にアクセスしちゃった時の処理追加してもらえると助かる
author_association: MEMBER
user: login: yukikamome316
created_at: 2025-04-06T06:53:45Z
body: 招待コードから登録しようとする人に表示されるメッセージを変えました。

<img width="1272" alt="スクリーンショット 2025-04-06 15 52 38" src="https://github.com/user-attachments/assets/16c05f2e-24b5-4afb-8f6c-6ea9575b677d" />

author_association: MEMBER