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)
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;
+}