diff --git a/.env.example b/.env.example index 69e0a22..f35cf0f 100644 --- a/.env.example +++ b/.env.example @@ -86,3 +86,28 @@ S3_MEDIA_SECRET_ACCESS_KEY= S3_MEDIA_ENDPOINT= S3_MEDIA_FORCE_PATH_STYLE=false S3_MEDIA_PRESIGN_TTL=15m + +# Discord Account Linking (OAuth2 on the backend; bot runs as a separate server) +DISCORD_ENABLED=false +DISCORD_CLIENT_ID= +DISCORD_CLIENT_SECRET= +# redirect_uri = the URL the BROWSER hits at the backend callback. Register it +# verbatim in the Discord Developer Portal (OAuth2 > Redirects; multiple allowed) +# and keep it identical here (scheme/host/port/path all matter). +# - local direct: http://localhost:8080/api/discord/callback +# - production: https://internal.swua.kr/wargame/api/discord/callback +DISCORD_REDIRECT_URI=http://localhost:8080/api/discord/callback +DISCORD_OAUTH_SCOPES=identify guilds.join +DISCORD_STATE_TTL=5m +DISCORD_OAUTH_TIMEOUT=10s +# Where to send the browser after the OAuth callback (frontend profile page). +# - production: https://wargame.swua.kr/profile +DISCORD_SUCCESS_REDIRECT=http://localhost:3000/profile +# Invite link shown to users not yet in the guild (fallback button). +DISCORD_INVITE_URL= +# Auto-join the guild via guilds.join scope after linking. +DISCORD_AUTO_JOIN=true +# Connection to the separate Discord bot server. +DISCORD_BOT_BASE_URL=http://localhost:8083 +DISCORD_BOT_SECRET=change-me +DISCORD_BOT_TIMEOUT=5s diff --git a/cmd/server/main.go b/cmd/server/main.go index 90080d1..bd5a848 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -12,6 +12,7 @@ import ( "wargame/internal/cache" "wargame/internal/config" "wargame/internal/db" + "wargame/internal/discord" httpserver "wargame/internal/http" "wargame/internal/logging" "wargame/internal/realtime" @@ -77,6 +78,7 @@ func main() { scoreRepo := repo.NewScoreboardRepo(database) stackRepo := repo.NewStackRepo(database) vmRepo := repo.NewVMRepo(database) + discordRepo := repo.NewDiscordRepo(database) var fileStore storage.ChallengeFileStore if cfg.S3.Enabled { @@ -126,6 +128,18 @@ func main() { vmClient := vm.NewClient(cfg.VM.OrchestratorBaseURL, cfg.VM.OrchestratorSecret, cfg.VM.OrchestratorTimeout) vmSvc := service.NewVMService(cfg.VM, vmRepo, challengeRepo, submissionRepo, vmClient, redisClient) + var discordSvc *service.DiscordService + if cfg.Discord.Enabled { + discordBotClient := discord.NewBotClient(cfg.Discord.BotBaseURL, cfg.Discord.BotSecret, cfg.Discord.BotTimeout) + discordOAuthClient := discord.NewOAuthClient(discord.OAuthConfig{ + ClientID: cfg.Discord.ClientID, + ClientSecret: cfg.Discord.ClientSecret, + RedirectURI: cfg.Discord.RedirectURI, + Scopes: cfg.Discord.Scopes, + }, cfg.Discord.OAuthTimeout) + discordSvc = service.NewDiscordService(cfg.Discord, discordRepo, discordBotClient, discordOAuthClient, redisClient) + } + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() @@ -136,7 +150,7 @@ func main() { leaderboardBus := realtime.NewScoreboardBus(redisClient, cfg, scoreSvc, logger) leaderboardBus.Start(ctx) - router := httpserver.NewRouter(cfg, authSvc, wargameSvc, userSvc, affiliationSvc, scoreSvc, stackSvc, vmSvc, popupSvc, redisClient, logger) + router := httpserver.NewRouter(cfg, authSvc, wargameSvc, userSvc, affiliationSvc, scoreSvc, stackSvc, vmSvc, popupSvc, discordSvc, redisClient, logger) srv := &nethttp.Server{ Addr: cfg.HTTPAddr, Handler: router, diff --git a/codecov.yaml b/codecov.yaml index 6ea5b9f..2d71c48 100644 --- a/codecov.yaml +++ b/codecov.yaml @@ -15,6 +15,7 @@ comment: ignore: - "frontend/**" + - "invite-bot/**" # standalone invite bot server (TypeScript), not part of Go coverage - "**/*_test.go" - "**/*_mock.go" - "**/*.pb.go" diff --git a/docker-compose.yaml b/docker-compose.yaml index c464de5..5f6965e 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -56,6 +56,7 @@ services: REDIS_ADDR: redis:6379 HTTP_ADDR: ":8080" VM_ORCH_BASE_URL: http://host.docker.internal:8082 + DISCORD_BOT_BASE_URL: http://invite-bot:8083 ports: - "8080:8080" depends_on: @@ -64,6 +65,16 @@ services: redis: condition: service_healthy + invite-bot: + build: + context: ./invite-bot + dockerfile: Dockerfile + restart: unless-stopped + env_file: + - ./invite-bot/.env + environment: + HTTP_ADDR: ":8083" + volumes: postgres_data: redis_data: diff --git a/docs/docs/discord.md b/docs/docs/discord.md new file mode 100644 index 0000000..29eef4c --- /dev/null +++ b/docs/docs/discord.md @@ -0,0 +1,224 @@ +--- +title: Discord +nav_order: 12 +--- + +Notes: + +- Discord account linking uses the OAuth2 authorization-code flow on the backend (`identify`, optionally `guilds.join`). +- Guild membership and role changes are performed by a separate **invite-bot** server over an internal HTTP API; the backend never holds the Discord bot token. The bot token, guild id, and verified-role id live on the bot server only. +- Access/refresh tokens are **not** stored. Role granting uses the bot token on the bot server, not the user's OAuth token. +- The acting user is identified by the JWT `access_token` cookie. `connect`, `callback`, and `status` require login; `sync-role` and `unlink` additionally require an unblocked (active) user. +- For authenticated `POST`, `PUT`, `PATCH`, and `DELETE` requests, send both `csrf_token` cookie and matching `X-CSRF-Token` header. +- All endpoints return `503 discord feature disabled` when `DISCORD_ENABLED=false`. + +## Discord Status Schema + +`discordStatusResponse` fields (omitted when empty): + +- `connected`: whether the current user has a linked Discord account. +- `discord_user_id`: linked Discord user id (snowflake). +- `discord_username`: Discord unique handle. +- `discord_global_name`: Discord display name. +- `discord_avatar`: Discord avatar hash. +- `role_status`: one of `CONNECTED`, `VERIFIED`, `NOT_IN_GUILD`, `ROLE_FAILED`, `REVOKED`, `LEFT_GUILD`. +- `connected_at`, `verified_at`: timestamps (UTC). +- `invite_url`: guild invite link to show users who are not yet in the guild (from `DISCORD_INVITE_URL`). + +## Start Linking + +`GET /api/discord/connect` + +Headers + +``` +Cookie: access_token= +``` + +Response 302 — redirect to the Discord authorize URL. The backend generates a single-use `state` (random, TTL `DISCORD_STATE_TTL`) bound to the current user and stores it in Redis. + +``` +Location: https://discord.com/oauth2/authorize?response_type=code&client_id=...&scope=identify+guilds.join&state=...&redirect_uri=...&prompt=consent +``` + +Errors: + +- 401 `invalid token` or `missing access_token cookie` +- 503 `discord feature disabled` +- 500 internal error + +--- + +## OAuth Callback + +`GET /api/discord/callback?code=&state=` + +Headers + +``` +Cookie: access_token= +``` + +Response 302 — redirect to `DISCORD_SUCCESS_REDIRECT` with a `?discord=` query. If `DISCORD_SUCCESS_REDIRECT` is empty, returns `200 {"discord":""}` instead. + +`` values: + +- `verified`: account linked, joined the guild, verified role granted. +- `connected_not_joined`: linked, but not a guild member (`NOT_IN_GUILD` / `LEFT_GUILD`). +- `role_failed`: linked, but the role could not be granted (bot permission / role hierarchy). +- `already_linked`: that Discord account is already linked to another user. +- `state_invalid`: `state` was missing, expired, forged, or did not match the user. +- `error`: token exchange / profile fetch failed, or another unexpected error. + +``` +Location: https://wargame.example.com/profile?discord=verified +``` + +Callback behavior: + +- `state` is validated with `GETDEL` and must match the user that started `connect` (CSRF protection); invalid `state` redirects with `discord=state_invalid`. +- The code is exchanged for an access token, then `/users/@me` is read to capture `discord_user_id`, `username`, `global_name`, `avatar`. +- The connection is upserted. `discord_user_id` is unique — reusing one already linked elsewhere redirects with `discord=already_linked`. +- When `DISCORD_AUTO_JOIN=true`, the backend asks the invite-bot to add the user to the guild (`guilds.join`) using the access token, which is forwarded once and never stored. +- The verified role is then requested through the invite-bot; `role_status` is persisted as `VERIFIED`, `NOT_IN_GUILD`, or `ROLE_FAILED`. + +Errors: + +- 401 `invalid token` or `missing access_token cookie` +- 503 `discord feature disabled` + +(Other failures are surfaced as the `?discord=` redirect query above, not as HTTP error codes.) + +--- + +## Get Link Status + +`GET /api/discord/status` + +Headers + +``` +Cookie: access_token= +``` + +Response 200 — linked + +```json +{ + "connected": true, + "discord_user_id": "900000000000000001", + "discord_username": "neo", + "discord_global_name": "Neo", + "discord_avatar": "a1b2c3d4e5f6", + "role_status": "VERIFIED", + "connected_at": "2026-06-23T11:00:00Z", + "verified_at": "2026-06-23T11:00:02Z", + "invite_url": "https://discord.gg/example" +} +``` + +Response 200 — not linked + +```json +{ + "connected": false, + "invite_url": "https://discord.gg/example" +} +``` + +Errors: + +- 401 `invalid token` or `missing access_token cookie` +- 503 `discord feature disabled` +- 500 internal error + +--- + +## Re-Check Role + +`POST /api/discord/sync-role` + +Headers + +``` +Cookie: access_token= +X-CSRF-Token: +``` + +Re-attempts guild join / verified-role grant for an already-linked user (used after the user joins the guild). Returns the updated `discordStatusResponse`. + +Response 200 — same schema as `GET /api/discord/status` (linked). + +Errors: + +- 401 `invalid token` or `missing access_token cookie` +- 403 `user blocked` +- 404 `discord account not connected` +- 429 `discord rate limited` +- 503 `discord feature disabled` or `discord bot server unavailable` +- 500 internal error + +--- + +## Unlink + +`DELETE /api/discord/unlink` + +Headers + +``` +Cookie: access_token= +X-CSRF-Token: +``` + +Response 200 + +```json +{ + "status": "ok" +} +``` + +Unlink behavior: + +- The invite-bot is asked to kick the user from the guild. This is best-effort: any error (bot down, missing permission, already gone) is logged and ignored. +- The connection row is then deleted. + +Errors: + +- 401 `invalid token` or `missing access_token cookie` +- 403 `user blocked` +- 404 `discord account not connected` +- 503 `discord feature disabled` +- 500 internal error + +--- + +## Configuration (ENV) + +Discord service options (backend): + +- `DISCORD_ENABLED` (default: `false`) +- `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET` +- `DISCORD_REDIRECT_URI` — must match a redirect registered in the Discord Developer Portal exactly. +- `DISCORD_OAUTH_SCOPES` (default: `identify guilds.join`) +- `DISCORD_STATE_TTL` (default: `5m`) +- `DISCORD_OAUTH_TIMEOUT` (default: `10s`) +- `DISCORD_SUCCESS_REDIRECT` — frontend page to return to after the callback. +- `DISCORD_INVITE_URL` — guild invite shown to users not yet in the guild. +- `DISCORD_AUTO_JOIN` (default: `true`) +- `DISCORD_BOT_BASE_URL` (default: `http://localhost:8083`) +- `DISCORD_BOT_SECRET` — shared bearer secret for the invite-bot internal API (must equal the bot's `DISCORD_INTERNAL_SECRET`). +- `DISCORD_BOT_TIMEOUT` (default: `5s`) + +Validation rules when `DISCORD_ENABLED=true`: + +- `DISCORD_CLIENT_ID` and `DISCORD_CLIENT_SECRET` must be set +- `DISCORD_REDIRECT_URI` must not be empty +- `DISCORD_OAUTH_SCOPES` must not be empty +- `DISCORD_BOT_BASE_URL` must not be empty +- `DISCORD_STATE_TTL > 0` +- `DISCORD_BOT_TIMEOUT > 0` +- `DISCORD_OAUTH_TIMEOUT > 0` + +The Discord bot token, guild id, and verified-role id are configured on the invite-bot server, not here. diff --git a/docs/invitebot.drawio.png b/docs/invitebot.drawio.png new file mode 100644 index 0000000..de29077 Binary files /dev/null and b/docs/invitebot.drawio.png differ 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} + />