Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
105 changes: 105 additions & 0 deletions app/api/auth/link/sub-discord/callback/route.ts
Original file line number Diff line number Diff line change
@@ -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");
}
}
90 changes: 90 additions & 0 deletions app/api/auth/link/sub-discord/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
110 changes: 110 additions & 0 deletions components/sub-discord-settings.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<svg
className={className}
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
>
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
</svg>
);
}

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 (
<div className="space-y-3">
<div>
<h3 className="text-sm font-semibold">サブDiscordアカウント</h3>
<p className="mt-1 text-xs text-muted-foreground leading-relaxed">
画面共有等で複数アカウントから同時に Voice Chat
に参加するためのサブアカウントを 1
つだけ連携できます。連携したサブアカウントでは Lumos Web
にログインできません。
</p>
</div>

<div className="flex items-center justify-between gap-2 p-4 border rounded-xl bg-card hover:shadow-md transition-all duration-200">
<div className="flex items-center gap-3 min-w-0">
<div className="w-10 h-10 bg-[#5865F2] rounded-xl flex items-center justify-center text-white shrink-0">
<DiscordIcon className="w-5 h-5" />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="font-medium text-sm">サブDiscord</p>
{subAccount && (
<span className="flex items-center justify-center w-4 h-4 rounded-full bg-green-500">
<Check className="w-2.5 h-2.5 text-white" strokeWidth={3} />
</span>
)}
</div>
{subAccount ? (
<p className="text-xs text-muted-foreground truncate">
@{subAccount.discordHandle ?? subAccount.discordUsername}
</p>
) : (
<p className="text-xs text-muted-foreground/60">未連携</p>
)}
</div>
</div>
<div className="shrink-0">
{subAccount ? (
<button
onClick={handleDisconnect}
disabled={disconnecting}
className="text-xs text-red-500 hover:text-red-600 border border-red-200 dark:border-red-900 hover:border-red-400 dark:hover:border-red-700 rounded-lg px-3 py-1.5 transition-all duration-200 disabled:opacity-50"
>
{disconnecting ? "解除中..." : "連携解除"}
</button>
) : (
<button
onClick={handleConnect}
className="flex items-center gap-2 bg-[#5865F2] hover:bg-[#4752c4] text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors"
>
<DiscordIcon className="w-4 h-4" />
Discordで連携
</button>
)}
</div>
</div>
</div>
);
}
Loading
Loading