From 15bcb021c42eb6f73a3fdbd0781e40bd862099dc Mon Sep 17 00:00:00 2001 From: yu23ki14 Date: Wed, 15 Jul 2026 16:56:12 +0900 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20=E3=82=A2=E3=83=97=E3=83=AA?= =?UTF-8?q?=E5=86=85=E3=81=8B=E3=82=89=20Discord=20=E9=80=A3=E6=90=BA?= =?UTF-8?q?=E3=81=B8=E3=81=AE=E5=B0=8E=E7=B7=9A=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ワークスペース設定に「外部サービス連携」セクションを追加し Discord bot ページ (//discord-bot) へ遷移できるようにする - メンバー詳細(自分)に「Discord を連携」ボタンを追加し /connect/discord?treeId=… へ導線を張る - /connect/discord に token が URL に無い場合の貼り付けフォールバックを追加 (DM リンク or 素の verifier_token JWT を抽出) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../composite/treat-emoji-slider.tsx | 10 +-- .../members/MemberDetailContent.tsx | 31 ++++--- .../frontend/app/routes/$treeId_.settings.tsx | 43 +++++++++ pkgs/frontend/app/routes/connect.discord.tsx | 88 +++++++++++++++++-- pkgs/frontend/hooks/useThanksToken.ts | 4 +- 5 files changed, 147 insertions(+), 29 deletions(-) diff --git a/pkgs/frontend/app/components/composite/treat-emoji-slider.tsx b/pkgs/frontend/app/components/composite/treat-emoji-slider.tsx index 0e66b451..ff748aa3 100644 --- a/pkgs/frontend/app/components/composite/treat-emoji-slider.tsx +++ b/pkgs/frontend/app/components/composite/treat-emoji-slider.tsx @@ -124,14 +124,8 @@ function TreatEmojiSlider({ return true; }, [max, onChange]); - const decrementPress = useRepeatingPress( - decrement, - !disabled && value > 0, - ); - const incrementPress = useRepeatingPress( - increment, - !disabled && value < max, - ); + const decrementPress = useRepeatingPress(decrement, !disabled && value > 0); + const incrementPress = useRepeatingPress(increment, !disabled && value < max); const { index: emojiKey, emoji } = useMemo( () => getTreatEmojiByRatio(value, max), diff --git a/pkgs/frontend/app/components/members/MemberDetailContent.tsx b/pkgs/frontend/app/components/members/MemberDetailContent.tsx index bc785fb5..43039697 100644 --- a/pkgs/frontend/app/components/members/MemberDetailContent.tsx +++ b/pkgs/frontend/app/components/members/MemberDetailContent.tsx @@ -7,6 +7,7 @@ import { useGetBalanceOfFractionTokens } from "hooks/useFractionToken"; import { useGetMintThanksTokens } from "hooks/useThanksToken"; import { useActiveWallet } from "hooks/useWallet"; import { type FC, useMemo, useState } from "react"; +import { SiDiscord } from "react-icons/si"; import { Link } from "react-router"; import type { HatsDetailSchama } from "types/hats"; import { ipfs2https } from "utils/ipfs"; @@ -181,16 +182,26 @@ export const MemberDetailContent: FC = ({ )} {isMe && ( - +
+ + {/* Personal wallet↔Discord binding. The verifier_token can't be + carried in-app, so /connect/discord shows a paste field when it + arrives without one; treeId lets it route to the bot page after. */} + +
)} diff --git a/pkgs/frontend/app/routes/$treeId_.settings.tsx b/pkgs/frontend/app/routes/$treeId_.settings.tsx index cc93e479..cbeec59a 100644 --- a/pkgs/frontend/app/routes/$treeId_.settings.tsx +++ b/pkgs/frontend/app/routes/$treeId_.settings.tsx @@ -16,6 +16,7 @@ import { useGetWorkspace } from "hooks/useWorkspace"; import type { NameData } from "namestone-sdk"; import { type FC, useCallback, useEffect, useMemo, useState } from "react"; import { LuCheck } from "react-icons/lu"; +import { SiDiscord } from "react-icons/si"; import { useNavigate, useParams } from "react-router"; import { toast } from "sonner"; import type { HatsDetailSchama } from "types/hats"; @@ -356,6 +357,47 @@ const OtherSection: FC = ({ treeId }) => { ); }; +interface ExternalIntegrationSectionProps { + treeId: string; +} + +// Workspace-level external-service links. The Discord bot page needs only the +// treeId (already in scope here), so it can be opened directly — no token / +// query param to prompt for. The personal wallet↔account binding +// (/connect/discord) lives on the member's own profile instead, since it needs +// a per-user verifier_token this admin surface can't supply. +const ExternalIntegrationSection: FC = ({ + treeId, +}) => { + const navigate = useNavigate(); + return ( + <> + 外部サービス連携 +
+ + + + + } + title="Discord bot 連携" + subtitle="/thx の mint 許可・Quest 代理申請を設定" + right={ + + } + onClick={() => navigate(`/${treeId}/discord-bot`)} + /> + +
+ + ); +}; + const authorityWearersQueryKey = (hatId: string | undefined) => ["authorityWearers", hatId ?? null] as const; @@ -786,6 +828,7 @@ const WorkspaceSettings: FC = () => {
+
diff --git a/pkgs/frontend/app/routes/connect.discord.tsx b/pkgs/frontend/app/routes/connect.discord.tsx index d763bfbe..196887a1 100644 --- a/pkgs/frontend/app/routes/connect.discord.tsx +++ b/pkgs/frontend/app/routes/connect.discord.tsx @@ -22,6 +22,7 @@ import { Button } from "~/components/ui/button"; import { Card, CardContent } from "~/components/ui/card"; import { Heading } from "~/components/ui/heading"; import { Icon } from "~/components/ui/icon"; +import { Textarea } from "~/components/ui/textarea"; import { Typography } from "~/components/ui/typography"; import { withBigIntJSON } from "~/lib/bigint-json"; @@ -45,6 +46,29 @@ function decodeVerifierClaims(token: string): VerifierClaims | null { } } +// Accepts what the user pastes when they arrive without a token in the URL +// (e.g. from an in-app "Discord を連携" link that can't carry the credential): +// either the full /toban-setup DM link (…/connect/discord#token= or +// ?token=) or a bare verifier_token JWT. Returns the extracted token, or +// null when nothing usable is found. +function extractVerifierToken(input: string): string | null { + const trimmed = input.trim(); + if (!trimmed) return null; + const inUrl = trimmed.match(/[#&?]token=([^&\s]+)/); + if (inUrl) { + try { + return decodeURIComponent(inUrl[1]); + } catch { + return inUrl[1]; + } + } + // Bare JWT: three base64url segments separated by dots. + if (/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(trimmed)) { + return trimmed; + } + return null; +} + // Maps the stable ConnectErrorCode set returned by `@toban/identity`'s // /api/connect handler. Anything not listed falls back to a generic // message; the codes themselves are the long-term API contract. @@ -108,9 +132,16 @@ const ConnectDiscord: FC = () => { // the wild. const tokenFromHash = useVerifierTokenFromHash(); const tokenFromQuery = params.get("token"); - const token = tokenFromHash ?? tokenFromQuery; + // Fallback for in-app entry points (e.g. a member's own profile) that link + // here but can't carry the verifier_token: the user pastes the DM link or the + // token itself and we promote it to the active token via `manualToken`. + const [manualToken, setManualToken] = useState(null); + const token = tokenFromHash ?? tokenFromQuery ?? manualToken; const treeId = params.get("treeId"); + const [pasteValue, setPasteValue] = useState(""); + const [pasteError, setPasteError] = useState(false); + const claims = useMemo( () => (token ? decodeVerifierClaims(token) : null), [token], @@ -272,15 +303,56 @@ const ConnectDiscord: FC = () => { if (!token) { return ( - - Discord 連携 - +
+ + 外部サービス連携 + + + Discord を連携 + +
- - - URL に verifier_token が含まれていません。Discord で - `/toban-setup` を実行してから、案内された URL を開いてください。 + + + Discord で /toban-setup を実行すると、連携用リンクが + DM + で届きます。そのリンクを開くと、この画面に自動で読み込まれます。 + + + 手動で進める場合は、DM に届いたリンク(または token + 文字列)を下に貼り付けてください。 +