diff --git a/.env.development b/.env.development index abb17d1..7e004a9 100644 --- a/.env.development +++ b/.env.development @@ -1 +1,4 @@ NEXT_PUBLIC_API_HOST = http://127.0.0.1:8080 + +# Skip OAuth proxy for local development (proxy server may be unavailable) +SKIP_OAUTH_PROXY = 1 diff --git a/README.md b/README.md index 95abfa6..5a40377 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,35 @@ Open-source [Hackathon][1] Platform with **Git-based Cloud Development Environme - PWA framework: [Workbox v6][9] - CI / CD: GitHub [Actions][10] + [Vercel][11] +## Environment Configuration + +Copy `.env` to `.env.local` and configure the following required variables: + +```bash +# GitHub OAuth (required for login) +GITHUB_OAUTH_CLIENT_ID=your_client_id +GITHUB_OAUTH_CLIENT_SECRET=your_client_secret + +# JWT Secret (required for session) +JWT_SECRET=your_jwt_secret + +# API Host +NEXT_PUBLIC_API_HOST=https://openhackathon-service.onrender.com + +# Skip OAuth proxy for local development (optional, already set in .env.development) +SKIP_OAUTH_PROXY=1 +``` + +### Creating GitHub OAuth App + +1. Go to [GitHub Developer Settings](https://github.com/settings/developers) +2. Click "New OAuth App" +3. Fill in: + - **Application name**: HOP Local Dev + - **Homepage URL**: `http://localhost:3000` + - **Authorization callback URL**: `http://localhost:3000/login` +4. Copy the Client ID and generate a Client Secret + ## Getting Started First, run the development server: diff --git a/components/User/UserBar.tsx b/components/User/UserBar.tsx index 57456f4..7d0273b 100644 --- a/components/User/UserBar.tsx +++ b/components/User/UserBar.tsx @@ -1,4 +1,6 @@ +import { Icon } from 'idea-react'; import { observer } from 'mobx-react'; +import { useRouter } from 'next/router'; import { useContext } from 'react'; import { Button, Dropdown } from 'react-bootstrap'; @@ -9,8 +11,10 @@ import LanguageMenu from './LanguageMenu'; const UserBar = observer(() => { const { t } = useContext(I18nContext), { user } = sessionStore; + const router = useRouter(); const showName = user?.name || user?.email || user?.mobilePhone || ''; + const loginUrl = `/login?redirect=${encodeURIComponent(router.asPath)}`; return ( <> @@ -18,24 +22,30 @@ const UserBar = observer(() => { {t('create_hackathons')} - {user && ( + {user ? ( {showName} + {t('profile')} {t('home_page')} sessionStore.signOut(true)} > {t('edit_profile')} + sessionStore.signOut(true)}> {t('sign_out')} + ) : ( + + + {t('sign_in')} + )} > diff --git a/docs/TASKS.md b/docs/TASKS.md new file mode 100644 index 0000000..68ee594 --- /dev/null +++ b/docs/TASKS.md @@ -0,0 +1,191 @@ +# HOP 开发任务清单(MVP → 完整网站) + +本文档用于把现有功能串联成可交付的网站,并将工作拆成可执行的任务列表。 + +- 当前工作分支:`feat/auth` +- 后端:已存在(前端以 API 集成为主) + +--- + +## 0. 现状速览(基于代码盘点) + +- 已有页面:`/`、`/activity`、`/activity/[name]`、`/activity/create`(受保护) +- 已有鉴权链路雏形:`pages/api/core.ts`(`githubOAuth2` + `jwtSigner` + `sessionGuard`) +- 已有会话模型:`models/User/Session.ts`(`getProfile()`、`signInWithGitHub()`、`signOut()`) +- 已有参赛/团队模型:`models/Activity/*`(`signOne()`、`team.joinTeam()` 等) + +--- + +## 1. MVP 目标(验收口径) + +### 1.1 参赛者旅程(MVP) + +- 能浏览 hackathon 列表与详情 +- 能完成 GitHub 登录 +- 能在详情页报名(Join/Sign up)并能看到报名状态 +- 能在「我的参赛」里看到自己报名的活动与状态 + +### 1.2 主办方旅程(MVP) + +- 能完成 GitHub 登录 +- 能创建 hackathon +- 创建成功后可跳转到该活动详情/管理入口 +- 能在「我发起的」里看到自己创建的活动 + +--- + +## 2. 里程碑与任务清单 + +> 说明:每个任务包含「验收标准」与「主要影响范围」。 + +### Milestone A:鉴权与登录态(对应分支 `feat/auth`,优先做) + +#### A1. 顶部导航增加“登录/退出/个人入口” + +- 优先级:P0 +- 验收标准: + - 未登录:导航显示“使用 GitHub 登录”按钮 + - 已登录:显示用户头像/用户名 + “退出”按钮 + “个人中心”入口 + - 退出后:cookie 被清理(`token`/`JWT`)且 UI 回到未登录态 +- 主要影响范围: + - `components/layout/MainNavigation.*` + - `models/User/Session.ts` + +#### A2. 新增个人中心最小页面 `/me` + +- 优先级:P0 +- 验收标准: + - 已登录访问 `/me`:展示用户基本信息(至少用户名、邮箱、头像) + - 未登录访问 `/me`:引导登录(跳转或提示) +- 主要影响范围: + - `pages/me.tsx`(新)或 `pages/user/me.tsx`(按现有结构定) + - 复用 `sessionGuard` 或前端登录态判断 + +#### A3. 会话初始化:全站启动时拉取 profile(可缓存) + +- 优先级:P0 +- 验收标准: + - 刷新页面后仍能正确恢复登录态 + - 如果 cookie 有 `JWT`,能拉到 `user/session` 并在导航展示 + - 如果 `JWT` 失效,能回到未登录态并提示一次即可 +- 主要影响范围: + - `pages/_app.tsx` + - `models/User/Session.ts` + +#### A4. 统一 401/403 体验(最小版本) + +- 优先级:P1 +- 验收标准: + - API 返回 401:统一提示“需要登录”并提供跳转/按钮 + - 受保护页面:未登录时不出现白屏/静默失败 +- 主要影响范围: + - `models/User/Session.ts` 的 HTTPClient middleware(或公共 client) + - 相关页面(`/activity/create`、未来的 `/me/*`) + +#### A5. 环境变量与 OAuth 配置校验 + +- 优先级:P1 +- 验收标准: + - 缺少 `GITHUB_OAUTH_CLIENT_ID/SECRET` 时给出明确报错(开发期) + - README 或 docs 中说明必须配置项 +- 主要影响范围: + - `pages/api/core.ts` + - `README.md` 或本 docs + +--- + +### Milestone B:参赛闭环(报名 → 状态 → 我的参赛) + +#### B1. 详情页报名动作与状态刷新 + +- 优先级:P0 +- 验收标准: + - `/activity/[name]` 点击报名:调用 `activityStore.signOne(name)` 成功 + - 成功后页面状态刷新:能展示 pending/approved/rejected 等状态 + - 报名窗口期外:按钮不可点击或不显示(按现有逻辑) +- 主要影响范围: + - `pages/activity/[name]/index.tsx` + - `models/Activity/index.ts` + +#### B2. 新增「我的参赛」页 `/me/registrations` + +- 优先级:P0 +- 验收标准: + - 能列出当前用户的报名记录(至少活动名、状态、链接) + - 空态友好(无报名时提示去活动列表) +- 主要影响范围: + - `pages/me/registrations.tsx`(新) + - 可能新增对应 model 方法(取决于后端已有 API) + +#### B3. 团队相关:approved 后引导创建/加入队伍 + +- 优先级:P1 +- 验收标准: + - 状态 approved 且活动期间:显示“创建队伍”或“加入队伍”入口 + - 加入/退出队伍操作成功后能更新当前队伍状态 +- 主要影响范围: + - `pages/activity/[name]/index.tsx` + - `models/Activity/Team.ts` + `components/Team/*` + +--- + +### Milestone C:发起闭环(创建 → 我发起的 → 管理入口) + +#### C1. 创建活动成功后的跳转与提示 + +- 优先级:P0 +- 验收标准: + - `/activity/create` 提交成功后:跳转到新活动详情 `/activity/[name]`(或管理页) + - 失败时:展示具体错误原因(字段校验/权限等) +- 主要影响范围: + - `components/Activity/ActivityEditor.tsx` + - `models/Activity/index.ts` + - `pages/activity/create.tsx` + +#### C2. 新增「我发起的」页 `/me/hackathons` + +- 优先级:P1 +- 验收标准: + - 能列出当前用户创建的活动 + - 每项可进入详情/管理入口 +- 主要影响范围: + - `pages/me/hackathons.tsx`(新) + - 依赖后端是否提供“按用户过滤”的 API + +#### C3. 最小管理入口(可先只读) + +- 优先级:P2 +- 验收标准: + - 至少存在 `/activity/[name]/manage` 页面骨架 + - 受保护 + 权限校验(不是主办方则提示无权限) +- 主要影响范围: + - `pages/activity/[name]/manage/*`(如不存在则新建) + +--- + +## 3. 分支与 PR 拆分建议 + +- `feat/auth`(当前):Milestone A(A1-A5) +- 后续建议按功能拆分: + - `feat/registration-flow`:B1 + B2 + - `feat/team-flow`:B3 + - `feat/activity-create-flow`:C1 + - `feat/organizer-pages`:C2 + C3 + +--- + +## 4. 开发约定(最小) + +- 任何受保护页面优先使用现有 `sessionGuard`(SSR)或统一的登录态保护组件。 +- API_HOST: + - 生产:`.env` 指向远端 + - 开发:如果不启动本地后端,请将 `.env.development` 改为远端或实现自动 fallback(后续可单独任务处理)。 + +--- + +## 5. 待确认(用于把任务变成“可直接开工”的更细粒度 issue) + +- 后端是否已有: + - “我的报名列表”接口? + - “我发起的活动列表”接口? + - 组织方/管理员权限字段如何判断?(roles 还是 organizerId) diff --git a/models/User/Session.ts b/models/User/Session.ts index fa6f3df..b62d276 100644 --- a/models/User/Session.ts +++ b/models/User/Session.ts @@ -1,23 +1,39 @@ import { Base, User } from '@freecodecamp-chengdu/hop-service'; -import { HTTPClient } from 'koajax'; +import { HTTPClient, HTTPError } from 'koajax'; import { computed, observable } from 'mobx'; import { BaseModel, persist, restore, toggle } from 'mobx-restful'; import { buildURLData, setCookie } from 'web-utility'; import { API_HOST, isServer, JWT, token } from '../../configuration'; -export const ownClient = new HTTPClient({ baseURI: API_HOST, responseType: 'json' }).use( - ({ request }, next) => { +export const ownClient = new HTTPClient({ baseURI: API_HOST, responseType: 'json' }) + .use(({ request }, next) => { if (JWT) request.headers = { ...request.headers, Authorization: `Bearer ${JWT}` }; return next(); - }, -); + }) + .use(async ({ request }, next) => { + try { + return await next(); + } catch (error) { + if (error instanceof HTTPError) { + const { status } = error.response; + + if (status === 401 || status === 403) { + if (!isServer()) { + const currentPath = globalThis.location?.pathname || '/'; + const loginUrl = `/login?redirect=${encodeURIComponent(currentPath)}`; + + globalThis.location?.assign(loginUrl); + } + } + } + throw error; + } + }); export interface SessionUser - extends Base, - Record<'username' | 'email', string>, - Record<'confirmed' | 'blocked', boolean> { + extends Base, Record<'username' | 'email', string>, Record<'confirmed' | 'blocked', boolean> { provider: 'local' | 'github'; gender?: 'Female' | 'Male' | 'Other'; } diff --git a/pages/_app.tsx b/pages/_app.tsx index cefc28c..3ade92f 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -11,7 +11,8 @@ import Link from 'next/link'; import { Col, Container, Image, Row } from 'react-bootstrap'; import { MainNavigation } from '../components/layout/MainNavigation'; -import { isServer } from '../configuration'; +import { isServer, JWT } from '../configuration'; +import sessionStore from '../models/User/Session'; import { createI18nStore, i18n, @@ -45,6 +46,19 @@ export default class CustomApp extends App { if (tips) alert(tips); }); + + this.initSession(); + } + + async initSession() { + if (!JWT) return; + + try { + await sessionStore.getProfile(); + } catch (error) { + console.error('Session restore failed:', error); + sessionStore.signOut(); + } } render() { diff --git a/pages/activity/create.module.less b/pages/activity/create.module.less index 54dc844..f954eb6 100644 --- a/pages/activity/create.module.less +++ b/pages/activity/create.module.less @@ -4,12 +4,7 @@ } .form-card { - position: relative; - z-index: 10; - margin-top: -3rem; box-shadow: 0 1rem 3rem rgba(0, 0, 0, 0.1); - border-radius: 1.5rem; - background: white; } .icon-circle { diff --git a/pages/activity/create.tsx b/pages/activity/create.tsx index 7240130..e378630 100644 --- a/pages/activity/create.tsx +++ b/pages/activity/create.tsx @@ -30,12 +30,18 @@ const ActivityCreatePage: FC = observer(() => { - - - - - - + + + + + + + + + + + + > ); diff --git a/pages/api/core.ts b/pages/api/core.ts index 0f6c809..9435c60 100644 --- a/pages/api/core.ts +++ b/pages/api/core.ts @@ -89,15 +89,26 @@ export const jwtSigner: SSRM> = async ({ req, res }, } }; -const client_id = process.env.GITHUB_OAUTH_CLIENT_ID!, - client_secret = process.env.GITHUB_OAUTH_CLIENT_SECRET!; +const client_id = process.env.GITHUB_OAUTH_CLIENT_ID, + client_secret = process.env.GITHUB_OAUTH_CLIENT_SECRET; + +if (!client_id || !client_secret) { + console.error( + '[OAuth Config Error] Missing required environment variables:\n' + + ' - GITHUB_OAUTH_CLIENT_ID\n' + + ' - GITHUB_OAUTH_CLIENT_SECRET\n' + + 'Please configure them in .env.local or environment settings.', + ); +} export const ProxyBaseURL = 'https://test.hackathon.fcc-cd.dev/proxy'; +const useProxy = !VERCEL && !process.env.SKIP_OAUTH_PROXY; + export const githubSigner = githubOAuth2({ - rootBaseURL: VERCEL ? undefined : `${ProxyBaseURL}/github.com/`, - client_id, - client_secret, + rootBaseURL: useProxy ? `${ProxyBaseURL}/github.com/` : undefined, + client_id: client_id!, + client_secret: client_secret!, scopes: ['user:email', 'read:user', 'public_repo', 'read:project'], }); diff --git a/pages/login.tsx b/pages/login.tsx new file mode 100644 index 0000000..6305f60 --- /dev/null +++ b/pages/login.tsx @@ -0,0 +1,37 @@ +import { User } from '@freecodecamp-chengdu/hop-service'; +import { JWTProps } from 'next-ssr-middleware'; +import { useRouter } from 'next/router'; +import { FC, useEffect } from 'react'; +import { Container, Spinner } from 'react-bootstrap'; + +import { PageHead } from '../components/layout/PageHead'; +import sessionStore from '../models/User/Session'; +import { sessionGuard } from './api/core'; + +export const getServerSideProps = sessionGuard; + +const LoginPage: FC> = ({ jwtPayload }) => { + const router = useRouter(); + const { redirect } = router.query; + + useEffect(() => { + if (jwtPayload) { + sessionStore.user = jwtPayload; + + const targetUrl = typeof redirect === 'string' ? redirect : '/'; + router.replace(targetUrl); + } + }, [jwtPayload, redirect, router]); + + return ( + <> + + + + 正在登录,请稍候... + + > + ); +}; + +export default LoginPage; diff --git a/pages/me/index.module.less b/pages/me/index.module.less new file mode 100644 index 0000000..585bd71 --- /dev/null +++ b/pages/me/index.module.less @@ -0,0 +1,18 @@ +.hero-section { + background: radial-gradient(circle at top left, #312e81, #0f172a 55%, #020617); + padding: 3rem 0 6rem; +} + +.info-item { + transition: background 0.2s ease; + background: rgba(59, 130, 246, 0.04); + + &:hover { + background: rgba(59, 130, 246, 0.08); + } +} + +.info-icon { + background: rgba(59, 130, 246, 0.12); + color: #2563eb; +} diff --git a/pages/me/index.tsx b/pages/me/index.tsx new file mode 100644 index 0000000..f244717 --- /dev/null +++ b/pages/me/index.tsx @@ -0,0 +1,199 @@ +import { User } from '@freecodecamp-chengdu/hop-service'; +import classNames from 'classnames'; +import { Icon } from 'idea-react'; +import { observer } from 'mobx-react'; +import { JWTProps } from 'next-ssr-middleware'; +import { FC, useContext, useEffect } from 'react'; +import { Badge, Button, Card, Col, Container, Image, Row } from 'react-bootstrap'; + +import { PageHead } from '../../components/layout/PageHead'; +import { I18nContext } from '../../models/Base/Translation'; +import sessionStore from '../../models/User/Session'; +import { sessionGuard } from '../api/core'; +import styles from './index.module.less'; + +export const getServerSideProps = sessionGuard; + +const ProfilePage: FC> = observer(({ jwtPayload }) => { + const { t } = useContext(I18nContext); + + useEffect(() => { + if (jwtPayload) sessionStore.user = jwtPayload; + }, [jwtPayload]); + + const user = jwtPayload || sessionStore.user; + + if (!user) return null; + + const { name, email, avatar, mobilePhone } = user; + + return ( + <> + + + + + + {t('profile')} + + {name || t('mystery_hacker')} + {email || t('mystery_hacker')} + + + + + + + + {avatar ? ( + + ) : ( + + + + )} + + + + + + + + + + + + {t('mail')} + {email || '-'} + + + + + + + + + + {t('phone_number')} + {mobilePhone || '-'} + + + + + + + + + + {t('role_source')} + GitHub + + + + + + + + + + {t('user_name')} + {name || '-'} + + + + + + + + + + + {t('home_page')} + + + + {t('edit_profile')} + + sessionStore.signOut(true)} + className="rounded-3 px-4" + > + + {t('sign_out')} + + + + + + + + > + ); +}); + +export default ProfilePage; diff --git a/pages/user/[id].module.less b/pages/user/[id].module.less new file mode 100644 index 0000000..ea4cd66 --- /dev/null +++ b/pages/user/[id].module.less @@ -0,0 +1,46 @@ +.hero-section { + background: radial-gradient(circle at top left, #312e81, #0f172a 55%, #020617); + padding: 3rem 0 6rem; +} + +.social-btn { + transition: all 0.2s ease; + background: rgba(59, 130, 246, 0.08); + color: #64748b; + + &:hover { + background: #2563eb; + color: white; + } + + &.active { + background: #2563eb; + color: white; + } +} + +.custom-tabs { + gap: 0.5rem; + margin-bottom: 1.5rem; + border: none; + + :global(.nav-link) { + transition: all 0.2s ease; + border: none; + border-radius: 0.75rem; + background: rgba(59, 130, 246, 0.05); + padding: 0.75rem 1.5rem; + color: #64748b; + font-weight: 500; + + &:hover { + background: rgba(59, 130, 246, 0.1); + color: #2563eb; + } + + &:global(.active) { + background: #2563eb; + color: white; + } + } +} diff --git a/pages/user/[id].tsx b/pages/user/[id].tsx index f38a36b..6e3e42c 100644 --- a/pages/user/[id].tsx +++ b/pages/user/[id].tsx @@ -1,15 +1,16 @@ -import { faGithub, faQq, faWeibo, faWeixin } from '@fortawesome/free-brands-svg-icons'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { User } from '@freecodecamp-chengdu/hop-service'; +import classNames from 'classnames'; +import { Icon } from 'idea-react'; import { observer } from 'mobx-react'; import dynamic from 'next/dynamic'; import { cache, compose, errorLogger } from 'next-ssr-middleware'; import { FC, useContext } from 'react'; -import { Card, Col, Container, Row, Tab, Tabs } from 'react-bootstrap'; +import { Badge, Card, Col, Container, Image, Nav, Row, Tab } from 'react-bootstrap'; import { PageHead } from '../../components/layout/PageHead'; import { I18nContext } from '../../models/Base/Translation'; import userStore from '../../models/User'; +import styles from './[id].module.less'; const ActivityList = dynamic(() => import('../../components/Activity/ActivityList'), { ssr: false, @@ -22,53 +23,149 @@ export const getServerSideProps = compose<{ id?: string }, User>( JSON.parse(JSON.stringify({ props: await userStore.getOne(id) })), ); -const UserDetailPage: FC = observer(({ id, name, avatar }) => { +const UserDetailPage: FC = observer(({ id, name, avatar, email }) => { const { t } = useContext(I18nContext); return ( - - + <> + - + + + + {t('hacker_pavilion')} + + {name || t('mystery_hacker')} + {email && {email}} + + + + - - - - {name} - - - - - - - - - + + + {avatar ? ( + + ) : ( + + + + )} + + + + + {name || t('mystery_hacker')} + {email && {email}} + + + + + + + + + + + + + + + - - - - - - - - - - - - + + + + + + + + + + {t('followed_hackathons')} + + + + + + {t('owned_hackathons')} + + + + + + {t('joined_hackathons')} + + + + + + + + + + + + + + + + + + - + > ); }); + export default UserDetailPage;
正在登录,请稍候...
{email || t('mystery_hacker')}
{email}