From 56e1ac0e6c196ddbe1cdb3666427b440ed625c2d Mon Sep 17 00:00:00 2001 From: Kim Jun Young Date: Tue, 23 Jun 2026 20:13:43 +0900 Subject: [PATCH 1/5] feat: add discord account integration feature --- .../UserProfile/DiscordLinkCard.tsx | 165 ++++++++++++++++++ frontend/src/lib/api.ts | 5 + frontend/src/lib/types.ts | 14 ++ frontend/src/locales/en.json | 22 ++- frontend/src/locales/ja.json | 22 ++- frontend/src/locales/ko.json | 22 ++- frontend/src/routes/ChallengeDetail.tsx | 6 +- frontend/src/routes/UserProfile.tsx | 3 + frontend/src/routes/admin/Popups.tsx | 33 +++- 9 files changed, 281 insertions(+), 11 deletions(-) create mode 100644 frontend/src/components/UserProfile/DiscordLinkCard.tsx diff --git a/frontend/src/components/UserProfile/DiscordLinkCard.tsx b/frontend/src/components/UserProfile/DiscordLinkCard.tsx new file mode 100644 index 0000000..7db699a --- /dev/null +++ b/frontend/src/components/UserProfile/DiscordLinkCard.tsx @@ -0,0 +1,165 @@ +import { useCallback, useEffect, useState } from 'react' + +import { ApiError } from '../../lib/api' +import { useT } from '../../lib/i18n' +import type { DiscordStatus } from '../../lib/types' +import { useApi } from '../../lib/useApi' + +type Banner = { kind: 'success' | 'error'; message: string } | null + +const discordAvatarUrl = (id?: string, avatar?: string): string | null => { + if (!id) return null + if (avatar) { + const ext = avatar.startsWith('a_') ? 'gif' : 'png' + return `https://cdn.discordapp.com/avatars/${id}/${avatar}.${ext}?size=64` + } + try { + const index = Number(BigInt(id) >> 22n) % 6 + return `https://cdn.discordapp.com/embed/avatars/${index}.png` + } catch { + return null + } +} + +const RESULT_KEYS: Record = { + verified: { kind: 'success', key: 'profile.discord.resultVerified' }, + connected_not_joined: { kind: 'success', key: 'profile.discord.resultNotJoined' }, + role_failed: { kind: 'error', key: 'profile.discord.resultRoleFailed' }, + already_linked: { kind: 'error', key: 'profile.discord.resultAlreadyLinked' }, + state_invalid: { kind: 'error', key: 'profile.discord.resultStateInvalid' }, + error: { kind: 'error', key: 'profile.discord.resultError' }, +} + +const DiscordLinkCard = () => { + const t = useT() + const api = useApi() + + const [status, setStatus] = useState(null) + const [loading, setLoading] = useState(true) + const [busy, setBusy] = useState(false) + const [banner, setBanner] = useState(null) + + const refresh = useCallback(async () => { + try { + const data = await api.discordStatus() + setStatus(data) + } catch { + setBanner({ kind: 'error', message: t('profile.discord.loadError') }) + } finally { + setLoading(false) + } + }, [api, t]) + + useEffect(() => { + const params = new URLSearchParams(window.location.search) + const result = params.get('discord') + if (result && RESULT_KEYS[result]) { + const mapping = RESULT_KEYS[result] + setBanner({ kind: mapping.kind, message: t(mapping.key) }) + params.delete('discord') + const query = params.toString() + window.history.replaceState({}, '', `${window.location.pathname}${query ? `?${query}` : ''}`) + } + }, [t]) + + useEffect(() => { + void refresh() + }, [refresh]) + + const onConnect = () => { + window.location.href = api.discordConnectUrl() + } + + const onSync = async () => { + setBusy(true) + setBanner(null) + try { + const data = await api.discordSyncRole() + setStatus(data) + } catch (error) { + const message = error instanceof ApiError ? error.message : t('profile.discord.resultError') + setBanner({ kind: 'error', message }) + } finally { + setBusy(false) + } + } + + const onUnlink = async () => { + setBusy(true) + setBanner(null) + try { + await api.discordUnlink() + setStatus({ connected: false, invite_url: status?.invite_url }) + } catch (error) { + const message = error instanceof ApiError ? error.message : t('profile.discord.resultError') + setBanner({ kind: 'error', message }) + } finally { + setBusy(false) + } + } + + const wrapper = 'mt-6 rounded-none border-0 bg-transparent p-0 shadow-none md:rounded-lg md:border md:border-border md:bg-surface md:p-6' + + const roleStatus = status?.role_status + const verified = roleStatus === 'VERIFIED' + const notJoined = roleStatus === 'NOT_IN_GUILD' || roleStatus === 'LEFT_GUILD' + const displayName = status?.discord_global_name || status?.discord_username + const avatarUrl = discordAvatarUrl(status?.discord_user_id, status?.discord_avatar) + + return ( +
+

{t('profile.discord.title')}

+

{t('profile.discord.description')}

+ + {banner ?

{banner.message}

: null} + + {loading ? ( +
+
+
+
+ ) : !status?.connected ? ( +
+ +
+ ) : ( +
+
+ {avatarUrl ? {displayName :
} +
+
{displayName}
+ {status.discord_username ?
@{status.discord_username}
: null} + {status.discord_user_id ?
ID: {status.discord_user_id}
: null} +
+
+ + {verified ?

{t('profile.discord.verified')}

: null} + {notJoined ?

{t('profile.discord.notJoined')}

: null} + {roleStatus === 'ROLE_FAILED' ?

{t('profile.discord.roleFailed')}

: null} + +
+ {notJoined && status.invite_url ? ( + + {t('profile.discord.joinServer')} + + ) : null} + + {!verified ? ( + + ) : null} + + +
+
+ )} +
+ ) +} + +export default DiscordLinkCard diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 858d6f4..8afd9ae 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -3,6 +3,7 @@ import type { AuthUser, Challenge, ChallengeDetail, + DiscordStatus, ChallengesResponse, ChallengeCreatePayload, ChallengeCreateResponse, @@ -671,6 +672,10 @@ export const createApi = ({ setAuthUser, clearAuth, translate }: ApiDeps) => { vms: Array.isArray(data?.vms) ? data.vms : [], } as AdminVMsResponse }, + discordConnectUrl: () => `${API_BASE}/api/discord/connect`, + discordStatus: () => request(`/api/discord/status`, { auth: true, noCache: true }), + discordSyncRole: () => request(`/api/discord/sync-role`, { method: 'POST', auth: true }), + discordUnlink: () => request<{ status?: string }>(`/api/discord/unlink`, { method: 'DELETE', auth: true }), adminVM: (vmId: string) => request(`/api/admin/vms/${vmId}`, { auth: true }), deleteAdminVM: (vmId: string) => request(`/api/admin/vms/${vmId}`, { method: 'DELETE', auth: true }), blockUser: (id: number, reason: string) => request(`/api/admin/users/${id}/block`, { method: 'POST', body: { reason }, auth: true }), diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 27c303b..2e5c5c5 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -194,6 +194,20 @@ export interface VM { created_by_username: string } +export type DiscordRoleStatus = 'CONNECTED' | 'VERIFIED' | 'NOT_IN_GUILD' | 'ROLE_FAILED' | 'REVOKED' | 'LEFT_GUILD' + +export interface DiscordStatus { + connected: boolean + discord_user_id?: string + discord_username?: string + discord_global_name?: string + discord_avatar?: string + role_status?: DiscordRoleStatus + connected_at?: string + verified_at?: string + invite_url?: string +} + export interface AdminVMListItem { vm_id: string ttl_expires_at?: string | null diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index f93dd5a..8b0d3bb 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -557,5 +557,25 @@ "challengeComment.placeholder": "Write a comment", "challengeComment.submit": "Post", "challengeComment.empty": "No comments yet.", - "challengeComment.loginRequired": "Login is required to post comments." + "challengeComment.loginRequired": "Login is required to post comments.", + "profile.discord.title": "Discord", + "profile.discord.description": "Link your Discord account to get the verified role on our server.", + "profile.discord.connect": "Link Discord account", + "profile.discord.connected": "Connected", + "profile.discord.verified": "Verified — the role has been granted.", + "profile.discord.notJoined": "Connected, but you haven't joined the Discord server yet.", + "profile.discord.roleFailed": "Connected, but the role could not be granted. Please contact an admin.", + "profile.discord.joinServer": "Join the Discord server", + "profile.discord.recheck": "Re-check role", + "profile.discord.unlink": "Unlink", + "profile.discord.unlinking": "Unlinking…", + "profile.discord.checking": "Re-checking…", + "profile.discord.account": "Discord account", + "profile.discord.resultVerified": "Discord account linked and verified role granted.", + "profile.discord.resultNotJoined": "Discord account linked. Join the server, then re-check your role.", + "profile.discord.resultRoleFailed": "Discord account linked, but the role could not be granted.", + "profile.discord.resultAlreadyLinked": "That Discord account is already linked to another user.", + "profile.discord.resultStateInvalid": "The link request expired. Please try again.", + "profile.discord.resultError": "Failed to link Discord. Please try again.", + "profile.discord.loadError": "Failed to load Discord status." } diff --git a/frontend/src/locales/ja.json b/frontend/src/locales/ja.json index 1924a48..027acff 100644 --- a/frontend/src/locales/ja.json +++ b/frontend/src/locales/ja.json @@ -557,5 +557,25 @@ "challengeComment.placeholder": "コメントを入力してください", "challengeComment.submit": "投稿", "challengeComment.empty": "まだコメントがありません。", - "challengeComment.loginRequired": "コメント投稿にはログインが必要です。" + "challengeComment.loginRequired": "コメント投稿にはログインが必要です。", + "profile.discord.title": "Discord", + "profile.discord.description": "Discord アカウントを連携すると、サーバーで認証済みロールが付与されます。", + "profile.discord.connect": "Discord アカウントを連携", + "profile.discord.connected": "連携済み", + "profile.discord.verified": "認証完了 — ロールが付与されました。", + "profile.discord.notJoined": "連携済みですが、まだ Discord サーバーに参加していません。", + "profile.discord.roleFailed": "連携済みですが、ロールを付与できませんでした。管理者にお問い合わせください。", + "profile.discord.joinServer": "Discord サーバーに参加", + "profile.discord.recheck": "ロールを再確認", + "profile.discord.unlink": "連携解除", + "profile.discord.unlinking": "解除中…", + "profile.discord.checking": "確認中…", + "profile.discord.account": "Discord アカウント", + "profile.discord.resultVerified": "Discord アカウントを連携し、認証済みロールを付与しました。", + "profile.discord.resultNotJoined": "Discord アカウントを連携しました。サーバーに参加してからロールを再確認してください。", + "profile.discord.resultRoleFailed": "Discord アカウントを連携しましたが、ロールを付与できませんでした。", + "profile.discord.resultAlreadyLinked": "その Discord アカウントは既に別のユーザーに連携されています。", + "profile.discord.resultStateInvalid": "連携リクエストの有効期限が切れました。もう一度お試しください。", + "profile.discord.resultError": "Discord の連携に失敗しました。もう一度お試しください。", + "profile.discord.loadError": "Discord の状態を取得できませんでした。" } diff --git a/frontend/src/locales/ko.json b/frontend/src/locales/ko.json index 0e8cb28..6f20a49 100644 --- a/frontend/src/locales/ko.json +++ b/frontend/src/locales/ko.json @@ -557,5 +557,25 @@ "community.commentPlaceholder": "댓글을 작성해 주세요", "community.commentSubmit": "작성", "community.commentEmpty": "아직 댓글이 없습니다.", - "community.commentLoginRequired": "댓글을 작성하려면 로그인해 주세요." + "community.commentLoginRequired": "댓글을 작성하려면 로그인해 주세요.", + "profile.discord.title": "Discord", + "profile.discord.description": "Discord 계정을 연동하면 서버에서 인증됨 역할을 받을 수 있습니다.", + "profile.discord.connect": "Discord 계정 연결하기", + "profile.discord.connected": "연결됨", + "profile.discord.verified": "인증 완료 — 역할이 지급되었습니다.", + "profile.discord.notJoined": "연결되었지만 아직 Discord 서버에 가입하지 않았습니다.", + "profile.discord.roleFailed": "연결되었지만 역할 지급에 실패했습니다. 관리자에게 문의해 주세요.", + "profile.discord.joinServer": "Discord 서버 참가하기", + "profile.discord.recheck": "역할 다시 확인하기", + "profile.discord.unlink": "연결 해제", + "profile.discord.unlinking": "해제 중…", + "profile.discord.checking": "확인 중…", + "profile.discord.account": "Discord 계정", + "profile.discord.resultVerified": "Discord 계정이 연결되고 인증됨 역할이 지급되었습니다.", + "profile.discord.resultNotJoined": "Discord 계정이 연결되었습니다. 서버에 가입한 뒤 역할을 다시 확인해 주세요.", + "profile.discord.resultRoleFailed": "Discord 계정이 연결되었지만 역할 지급에 실패했습니다.", + "profile.discord.resultAlreadyLinked": "이미 다른 계정에 연결된 Discord 계정입니다.", + "profile.discord.resultStateInvalid": "인증 요청이 만료되었습니다. 다시 시도해 주세요.", + "profile.discord.resultError": "Discord 연결에 실패했습니다. 다시 시도해 주세요.", + "profile.discord.loadError": "Discord 상태를 불러오지 못했습니다." } diff --git a/frontend/src/routes/ChallengeDetail.tsx b/frontend/src/routes/ChallengeDetail.tsx index 636bd1a..bdede3b 100644 --- a/frontend/src/routes/ChallengeDetail.tsx +++ b/frontend/src/routes/ChallengeDetail.tsx @@ -807,7 +807,11 @@ const ChallengeDetail = ({ routeParams = {} }: RouteProps) => {

{t('challenge.vmInstance')}

{auth.user && stackInfo ? ( - ) : null} diff --git a/frontend/src/routes/UserProfile.tsx b/frontend/src/routes/UserProfile.tsx index b35c1b2..a8a1392 100644 --- a/frontend/src/routes/UserProfile.tsx +++ b/frontend/src/routes/UserProfile.tsx @@ -8,6 +8,7 @@ import { navigate } from '../lib/router' import { uploadPresignedPost } from '../lib/api' import ProfileHeader from '../components/UserProfile/ProfileHeader' import AccountCard from '../components/UserProfile/AccountCard' +import DiscordLinkCard from '../components/UserProfile/DiscordLinkCard' import ActiveStacksCard from '../components/UserProfile/ActiveStacksCard' import StatisticsCard from '../components/UserProfile/StatisticsCard' import { getLocaleTag, useLocale, useT } from '../lib/i18n' @@ -549,6 +550,8 @@ const UserProfile = ({ routeParams = {} }: RouteProps) => { onSaveAffiliation={saveAffiliation} /> + +

{t('profile.imageTitle')}

{t('profile.imageHint')}

diff --git a/frontend/src/routes/admin/Popups.tsx b/frontend/src/routes/admin/Popups.tsx index 5c3baf2..025112a 100644 --- a/frontend/src/routes/admin/Popups.tsx +++ b/frontend/src/routes/admin/Popups.tsx @@ -163,8 +163,20 @@ const AdminPopups = () => {

{t('admin.popups.title')}

- setTitle(event.target.value)} placeholder={t('admin.popups.titlePlaceholder')} disabled={saving} /> - setLinkURL(event.target.value)} placeholder={t('admin.popups.linkPlaceholder')} disabled={saving} /> + setTitle(event.target.value)} + placeholder={t('admin.popups.titlePlaceholder')} + disabled={saving} + /> + setLinkURL(event.target.value)} + placeholder={t('admin.popups.linkPlaceholder')} + disabled={saving} + />