From 333a9fa9cec7078bae01c1444395f9e0f9b626dc Mon Sep 17 00:00:00 2001 From: Shion Ichikawa Date: Mon, 27 Apr 2026 13:55:05 +0900 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=93=9D=20CLAUDE.md=20=E3=81=AB?= =?UTF-8?q?=E3=83=AB=E3=83=BC=E3=83=86=E3=82=A3=E3=83=B3=E3=82=B0/?= =?UTF-8?q?=E3=83=A2=E3=82=B8=E3=83=A5=E3=83=BC=E3=83=AB=E6=A7=8B=E6=88=90?= =?UTF-8?q?=E3=81=AE=E8=BF=BD=E8=A8=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 59a46da..4f4b8a1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,20 +51,22 @@ Tests require the Firestore emulator. `just test` wraps `firebase emulators:exec ### Routing (`app/`) - `app/page.tsx` — Public landing page -- `app/news/` — Public news/announcements -- `app/members/` — Public member directory +- `app/news/`, `app/members/`, `app/projects/`, `app/about/`, `app/contact/` — Public pages - `app/mini-lt/` — Mini LT (Lightning Talk) event management with admin panel - `app/internal/` — Protected member area (requires Discord auth) - `(protected)/` — Route group for authenticated pages (profile, members, settings, events) - `onboarding/` — New member onboarding flow -- `app/api/` — API routes for profile, events, onboarding, Discord integration, OAuth linking +- `app/optout/` — Two-step opt-out (Web form + Discord DM confirmation); see `docs/optout-flow.md` +- `app/api/` — API routes: `auth/`, `profile/`, `onboarding/`, `events/`, `discord/`, `optout/`, `line-invite/`, `survey/`, `webhook/` ### Key Modules - `lib/auth.ts` — NextAuth config: Discord OAuth, JWT callbacks, admin role detection via Discord guild membership - `lib/firebase.ts` — Firestore initialization and helpers; uses emulator in dev (`FIRESTORE_EMULATOR_HOST`) - `lib/members.ts` — Member CRUD operations against Firestore -- `lib/discord.ts` — Discord API integration (guild validation, event creation) +- `lib/discord.ts`, `lib/discord-guild.ts`, `lib/discord-dm.ts`, `lib/discord-optout.ts` — Discord API integration (guild validation, event creation, DM sending, opt-out flow) +- `lib/oauth-link.ts` — Secondary OAuth account linking helpers (GitHub, X, LINE) +- `lib/admin/` — Admin-only operations gated on Discord guild role - `lib/mini-lt/` — Mini LT module: Firestore ops (`firebase.ts`), utility functions (`utils.ts`), LINE Flex messages (`line-flex.ts`) - `lib/mini-lt/actions/` — Server Actions (`"use server"`): `discord-events.ts` (Discord event creation), `line.ts` (LINE push notifications) - `types/` — Shared TypeScript types (member, profile, event, interests, next-auth session augmentation) From 9c50157018bd86500be919bce5c8343b9a445e78 Mon Sep 17 00:00:00 2001 From: Shion Ichikawa Date: Wed, 6 May 2026 01:31:23 +0900 Subject: [PATCH 2/2] =?UTF-8?q?=E2=9C=A8=20=E3=82=B5=E3=83=96=E3=82=A2?= =?UTF-8?q?=E3=82=AB=E3=82=A6=E3=83=B3=E3=83=88=E9=80=A3=E6=90=BA=E6=A9=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/link/sub-discord/callback/route.ts | 105 +++++++++++ app/api/auth/link/sub-discord/route.ts | 90 +++++++++ components/sub-discord-settings.tsx | 110 +++++++++++ lib/sub-account.test.ts | 172 +++++++++++++++++ lib/sub-account.ts | 178 ++++++++++++++++++ 5 files changed, 655 insertions(+) create mode 100644 app/api/auth/link/sub-discord/callback/route.ts create mode 100644 app/api/auth/link/sub-discord/route.ts create mode 100644 components/sub-discord-settings.tsx create mode 100644 lib/sub-account.test.ts create mode 100644 lib/sub-account.ts diff --git a/app/api/auth/link/sub-discord/callback/route.ts b/app/api/auth/link/sub-discord/callback/route.ts new file mode 100644 index 0000000..3e781d6 --- /dev/null +++ b/app/api/auth/link/sub-discord/callback/route.ts @@ -0,0 +1,105 @@ +import { NextRequest, NextResponse } from "next/server"; +import { cookies } from "next/headers"; +import { linkSubAccount } from "@/lib/sub-account"; + +const DISCORD_TOKEN_URL = "https://discord.com/api/oauth2/token"; +const DISCORD_USER_URL = "https://discord.com/api/users/@me"; + +type DiscordUser = { + id: string; + username: string; + global_name?: string | null; + avatar?: string | null; +}; + +function buildAvatarUrl(user: DiscordUser): string { + if (!user.avatar) return ""; + return `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png`; +} + +export async function GET(request: NextRequest) { + const { searchParams } = request.nextUrl; + const code = searchParams.get("code"); + const state = searchParams.get("state"); + + const cookieStore = await cookies(); + const savedState = cookieStore.get("oauth_link_state_sub_discord")?.value; + const primaryDiscordId = cookieStore.get( + "oauth_link_primary_discord_id", + )?.value; + const redirectTo = + cookieStore.get("oauth_link_redirect")?.value ?? "/internal/settings"; + + cookieStore.delete("oauth_link_state_sub_discord"); + cookieStore.delete("oauth_link_primary_discord_id"); + cookieStore.delete("oauth_link_redirect"); + + const origin = process.env.AUTH_URL ?? request.nextUrl.origin; + const failRedirect = (errorCode: string) => { + const url = new URL(redirectTo, origin); + url.searchParams.set("error", errorCode); + return NextResponse.redirect(url.toString()); + }; + + if (!code || !state || state !== savedState || !primaryDiscordId) { + return failRedirect("sub_discord_link_failed"); + } + + const clientId = process.env.AUTH_DISCORD_ID; + const clientSecret = process.env.AUTH_DISCORD_SECRET; + if (!clientId || !clientSecret) { + return failRedirect("sub_discord_link_failed"); + } + + try { + const tokenRes = await fetch(DISCORD_TOKEN_URL, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + grant_type: "authorization_code", + code, + redirect_uri: `${origin}/api/auth/link/sub-discord/callback`, + }).toString(), + }); + const tokenData = await tokenRes.json(); + if (!tokenRes.ok || !tokenData.access_token) { + console.error("Sub-Discord token exchange failed:", tokenData); + return failRedirect("sub_discord_link_failed"); + } + + const userRes = await fetch(DISCORD_USER_URL, { + headers: { Authorization: `Bearer ${tokenData.access_token}` }, + }); + const user = (await userRes.json()) as DiscordUser; + if (!userRes.ok || !user.id) { + console.error("Sub-Discord user fetch failed:", user); + return failRedirect("sub_discord_link_failed"); + } + + const result = await linkSubAccount({ + primaryDiscordId, + sub: { + discordId: user.id, + username: user.global_name ?? user.username, + handle: user.username, + avatar: buildAvatarUrl(user), + }, + }); + + if (!result.ok) { + return failRedirect(`sub_discord_${result.error}`); + } + + const successUrl = new URL(redirectTo, origin); + successUrl.searchParams.set("success", "sub_discord_linked"); + return NextResponse.redirect(successUrl.toString()); + } catch (e) { + console.error("Sub-Discord link callback error:", e); + return failRedirect("sub_discord_link_failed"); + } +} diff --git a/app/api/auth/link/sub-discord/route.ts b/app/api/auth/link/sub-discord/route.ts new file mode 100644 index 0000000..7f7ba9c --- /dev/null +++ b/app/api/auth/link/sub-discord/route.ts @@ -0,0 +1,90 @@ +import { NextResponse } from "next/server"; +import { cookies } from "next/headers"; +import { auth } from "@/lib/auth"; +import { generateState } from "@/lib/oauth-link"; +import { unlinkSubAccount } from "@/lib/sub-account"; + +const DISCORD_AUTHORIZE_URL = "https://discord.com/api/oauth2/authorize"; +const SCOPE = "identify"; + +export async function GET(request: Request) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + if (session.user.optedOut) { + return NextResponse.json( + { error: "退会済みアカウントでは操作できません" }, + { status: 403 }, + ); + } + + const clientId = process.env.AUTH_DISCORD_ID; + if (!clientId) { + return NextResponse.json( + { error: "Discord OAuth not configured" }, + { status: 503 }, + ); + } + + const state = generateState(); + const baseUrl = process.env.AUTH_URL ?? "http://localhost:3000"; + const callbackUrl = `${baseUrl}/api/auth/link/sub-discord/callback`; + + const cookieStore = await cookies(); + const cookieBase = { + httpOnly: true, + maxAge: 600, + path: "/", + secure: process.env.NODE_ENV === "production", + sameSite: "lax" as const, + }; + cookieStore.set("oauth_link_state_sub_discord", state, cookieBase); + cookieStore.set("oauth_link_primary_discord_id", session.user.id, cookieBase); + cookieStore.set( + "oauth_link_redirect", + new URL(request.url).searchParams.get("redirectTo") ?? "/internal/settings", + cookieBase, + ); + + const url = new URL(DISCORD_AUTHORIZE_URL); + url.searchParams.set("client_id", clientId); + url.searchParams.set("redirect_uri", callbackUrl); + url.searchParams.set("response_type", "code"); + url.searchParams.set("scope", SCOPE); + url.searchParams.set("state", state); + // 毎回アカウント選択を強制 (メインと別アカウントを選ばせるため) + url.searchParams.set("prompt", "consent"); + + return NextResponse.redirect(url.toString()); +} + +export async function DELETE(request: Request) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + if (session.user.optedOut) { + return NextResponse.json( + { error: "退会済みアカウントでは操作できません" }, + { status: 403 }, + ); + } + + const body = (await request.json().catch(() => null)) as { + subDiscordId?: string; + } | null; + if (!body?.subDiscordId) { + return NextResponse.json({ error: "Bad Request" }, { status: 400 }); + } + + const result = await unlinkSubAccount({ + primaryDiscordId: session.user.id, + subDiscordId: body.subDiscordId, + }); + + if (!result.ok) { + return NextResponse.json({ error: result.error }, { status: 400 }); + } + return NextResponse.json({ ok: true }); +} diff --git a/components/sub-discord-settings.tsx b/components/sub-discord-settings.tsx new file mode 100644 index 0000000..3f46cdb --- /dev/null +++ b/components/sub-discord-settings.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { Check } from "lucide-react"; + +function DiscordIcon({ className }: { className?: string }) { + return ( + + ); +} + +interface Props { + subAccount: { + discordId: string; + discordUsername: string; + discordHandle?: string; + } | null; +} + +export default function SubDiscordSettings({ subAccount }: Props) { + const router = useRouter(); + const [disconnecting, setDisconnecting] = useState(false); + + const handleConnect = () => { + window.location.href = "/api/auth/link/sub-discord"; + }; + + const handleDisconnect = async () => { + if (!subAccount) return; + if (!confirm("サブDiscordアカウントの連携を解除しますか?")) return; + setDisconnecting(true); + const res = await fetch("/api/auth/link/sub-discord", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ subDiscordId: subAccount.discordId }), + }); + setDisconnecting(false); + if (res.ok) { + router.refresh(); + } else { + alert("解除に失敗しました。"); + } + }; + + return ( +
+
+

サブDiscordアカウント

+

+ 画面共有等で複数アカウントから同時に Voice Chat + に参加するためのサブアカウントを 1 + つだけ連携できます。連携したサブアカウントでは Lumos Web + にログインできません。 +

+
+ +
+
+
+ +
+
+
+

サブDiscord

+ {subAccount && ( + + + + )} +
+ {subAccount ? ( +

+ @{subAccount.discordHandle ?? subAccount.discordUsername} +

+ ) : ( +

未連携

+ )} +
+
+
+ {subAccount ? ( + + ) : ( + + )} +
+
+
+ ); +} diff --git a/lib/sub-account.test.ts b/lib/sub-account.test.ts new file mode 100644 index 0000000..22f6bfb --- /dev/null +++ b/lib/sub-account.test.ts @@ -0,0 +1,172 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import * as firebaseAdmin from "firebase-admin"; +import { + linkSubAccount, + unlinkSubAccount, + getSubAccount, + isSubAccountDiscordId, +} from "./sub-account"; + +if (!firebaseAdmin.apps.length) { + firebaseAdmin.initializeApp({ + projectId: process.env.FIREBASE_PROJECT_ID || "test-project", + }); +} + +const db = firebaseAdmin.firestore(); + +const PRIMARY = "primary-id"; +const SUB = "sub-id"; +const OTHER_PRIMARY = "other-primary-id"; +const SUB_PROFILE = { + discordId: SUB, + username: "Sub User", + handle: "subuser", + avatar: "https://example.com/sub.png", +}; + +async function clearMembers() { + const snap = await db.collection("members").get(); + for (const doc of snap.docs) await doc.ref.delete(); +} + +async function seedPrimary(id: string) { + await db.collection("members").doc(id).set({ + discordUsername: "Primary", + discordAvatar: "", + onboardingCompleted: true, + }); +} + +describe("linkSubAccount", () => { + beforeEach(async () => { + await clearMembers(); + await seedPrimary(PRIMARY); + }); + + it("links a sub-account to a primary account and creates the sub doc", async () => { + const result = await linkSubAccount({ + primaryDiscordId: PRIMARY, + sub: SUB_PROFILE, + }); + expect(result.ok).toBe(true); + + const primaryDoc = await db.collection("members").doc(PRIMARY).get(); + expect(primaryDoc.data()?.subAccountDiscordId).toBe(SUB); + + const subDoc = await db.collection("members").doc(SUB).get(); + expect(subDoc.exists).toBe(true); + expect(subDoc.data()?.isSubAccount).toBe(true); + expect(subDoc.data()?.primaryDiscordId).toBe(PRIMARY); + expect(subDoc.data()?.discordUsername).toBe("Sub User"); + expect(subDoc.data()?.discordHandle).toBe("subuser"); + }); + + it("rejects linking the same Discord ID as the primary itself", async () => { + const result = await linkSubAccount({ + primaryDiscordId: PRIMARY, + sub: { ...SUB_PROFILE, discordId: PRIMARY }, + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toBe("self_link"); + }); + + it("rejects linking when the sub is already a Lumos main member", async () => { + await seedPrimary(SUB); // SUB already exists as a regular member + const result = await linkSubAccount({ + primaryDiscordId: PRIMARY, + sub: SUB_PROFILE, + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toBe("already_member"); + }); + + it("rejects linking when the sub is already linked to another primary", async () => { + await seedPrimary(OTHER_PRIMARY); + await linkSubAccount({ + primaryDiscordId: OTHER_PRIMARY, + sub: SUB_PROFILE, + }); + const result = await linkSubAccount({ + primaryDiscordId: PRIMARY, + sub: SUB_PROFILE, + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toBe("already_linked_to_other"); + }); + + it("rejects linking when primary already has a sub-account", async () => { + await linkSubAccount({ + primaryDiscordId: PRIMARY, + sub: SUB_PROFILE, + }); + const result = await linkSubAccount({ + primaryDiscordId: PRIMARY, + sub: { + discordId: "another-sub", + username: "Another", + avatar: "", + }, + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toBe("primary_has_sub"); + }); +}); + +describe("unlinkSubAccount", () => { + beforeEach(async () => { + await clearMembers(); + await seedPrimary(PRIMARY); + await linkSubAccount({ primaryDiscordId: PRIMARY, sub: SUB_PROFILE }); + }); + + it("removes the sub doc and clears subAccountDiscordId on primary", async () => { + const result = await unlinkSubAccount({ + primaryDiscordId: PRIMARY, + subDiscordId: SUB, + }); + expect(result.ok).toBe(true); + + const primaryDoc = await db.collection("members").doc(PRIMARY).get(); + expect(primaryDoc.data()?.subAccountDiscordId).toBeUndefined(); + + const subDoc = await db.collection("members").doc(SUB).get(); + expect(subDoc.exists).toBe(false); + }); + + it("rejects unlinking when the sub is not owned by the primary", async () => { + await seedPrimary(OTHER_PRIMARY); + const result = await unlinkSubAccount({ + primaryDiscordId: OTHER_PRIMARY, + subDiscordId: SUB, + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toBe("not_linked"); + }); +}); + +describe("getSubAccount / isSubAccountDiscordId", () => { + beforeEach(async () => { + await clearMembers(); + await seedPrimary(PRIMARY); + }); + + it("returns null when no sub-account is linked", async () => { + expect(await getSubAccount(PRIMARY)).toBeNull(); + }); + + it("returns the sub-account info after linking", async () => { + await linkSubAccount({ primaryDiscordId: PRIMARY, sub: SUB_PROFILE }); + const info = await getSubAccount(PRIMARY); + expect(info?.discordId).toBe(SUB); + expect(info?.discordUsername).toBe("Sub User"); + expect(info?.discordHandle).toBe("subuser"); + }); + + it("isSubAccountDiscordId returns true only for sub-account ids", async () => { + await linkSubAccount({ primaryDiscordId: PRIMARY, sub: SUB_PROFILE }); + expect(await isSubAccountDiscordId(SUB)).toBe(true); + expect(await isSubAccountDiscordId(PRIMARY)).toBe(false); + expect(await isSubAccountDiscordId("nonexistent")).toBe(false); + }); +}); diff --git a/lib/sub-account.ts b/lib/sub-account.ts new file mode 100644 index 0000000..7b2f1ef --- /dev/null +++ b/lib/sub-account.ts @@ -0,0 +1,178 @@ +import { getDb } from "@/lib/firebase"; +import { FieldValue } from "firebase-admin/firestore"; + +export type SubAccountInfo = { + discordId: string; + discordUsername: string; + discordHandle?: string; + discordAvatar: string; + linkedAt?: FirebaseFirestore.Timestamp; +}; + +export type LinkSubAccountResult = + | { ok: true } + | { + ok: false; + error: + | "self_link" + | "already_member" + | "already_linked_to_other" + | "primary_has_sub"; + }; + +/** + * メインアカウントにサブアカウントを連携する。 + * メイン側 doc に subAccountDiscordId をセットし、サブ側 doc を新規作成する。 + * 両方の更新を 1 つのトランザクションで行う。 + */ +export async function linkSubAccount(params: { + primaryDiscordId: string; + sub: { + discordId: string; + username: string; + handle?: string; + avatar: string; + }; +}): Promise { + const { primaryDiscordId, sub } = params; + + if (primaryDiscordId === sub.discordId) { + return { ok: false, error: "self_link" }; + } + + const db = getDb(); + const primaryRef = db.collection("members").doc(primaryDiscordId); + const subRef = db.collection("members").doc(sub.discordId); + + return await db.runTransaction(async (tx) => { + const [primarySnap, subSnap] = await Promise.all([ + tx.get(primaryRef), + tx.get(subRef), + ]); + + const primaryData = primarySnap.data() ?? {}; + if (primaryData.subAccountDiscordId) { + return { ok: false, error: "primary_has_sub" } as LinkSubAccountResult; + } + + if (subSnap.exists) { + const subData = subSnap.data() ?? {}; + // 既に Lumos の (メイン) メンバーとして登録済み + if (subData.isSubAccount !== true) { + return { ok: false, error: "already_member" } as LinkSubAccountResult; + } + // 既に別のメインに紐づいているサブ + if ( + subData.primaryDiscordId && + subData.primaryDiscordId !== primaryDiscordId + ) { + return { + ok: false, + error: "already_linked_to_other", + } as LinkSubAccountResult; + } + } + + tx.set( + subRef, + { + isSubAccount: true, + primaryDiscordId, + discordUsername: sub.username, + ...(sub.handle ? { discordHandle: sub.handle } : {}), + discordAvatar: sub.avatar, + linkedAt: FieldValue.serverTimestamp(), + createdAt: FieldValue.serverTimestamp(), + updatedAt: FieldValue.serverTimestamp(), + }, + { merge: true }, + ); + + tx.update(primaryRef, { + subAccountDiscordId: sub.discordId, + updatedAt: FieldValue.serverTimestamp(), + }); + + return { ok: true } as LinkSubAccountResult; + }); +} + +/** + * メインアカウントに連携されたサブアカウントを解除する。 + * メイン側の subAccountDiscordId を削除し、サブ側のドキュメントを削除する。 + */ +export async function unlinkSubAccount(params: { + primaryDiscordId: string; + subDiscordId: string; +}): Promise<{ ok: true } | { ok: false; error: "not_linked" | "not_owner" }> { + const { primaryDiscordId, subDiscordId } = params; + const db = getDb(); + const primaryRef = db.collection("members").doc(primaryDiscordId); + const subRef = db.collection("members").doc(subDiscordId); + + return await db.runTransaction(async (tx) => { + const [primarySnap, subSnap] = await Promise.all([ + tx.get(primaryRef), + tx.get(subRef), + ]); + + if ( + !primarySnap.exists || + primarySnap.data()?.subAccountDiscordId !== subDiscordId + ) { + return { ok: false, error: "not_linked" } as const; + } + if ( + !subSnap.exists || + subSnap.data()?.primaryDiscordId !== primaryDiscordId + ) { + return { ok: false, error: "not_owner" } as const; + } + + tx.update(primaryRef, { + subAccountDiscordId: FieldValue.delete(), + updatedAt: FieldValue.serverTimestamp(), + }); + tx.delete(subRef); + + return { ok: true } as const; + }); +} + +/** + * メインアカウントに紐づくサブアカウント情報を取得する。 + */ +export async function getSubAccount( + primaryDiscordId: string, +): Promise { + const db = getDb(); + const primarySnap = await db + .collection("members") + .doc(primaryDiscordId) + .get(); + const subId = primarySnap.data()?.subAccountDiscordId as string | undefined; + if (!subId) return null; + + const subSnap = await db.collection("members").doc(subId).get(); + if (!subSnap.exists) return null; + const data = subSnap.data() ?? {}; + return { + discordId: subId, + discordUsername: data.discordUsername ?? "", + discordHandle: data.discordHandle, + discordAvatar: data.discordAvatar ?? "", + linkedAt: data.linkedAt, + }; +} + +/** + * 与えられた discordId がサブアカウントとして登録済みかを判定する。 + * NextAuth の signIn callback でログイン拒否に使う。 + */ +export async function isSubAccountDiscordId( + discordId: string, +): Promise { + const db = getDb(); + const snap = await db.collection("members").doc(discordId).get(); + return snap.exists && snap.data()?.isSubAccount === true; +}