Skip to content
Open
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
3 changes: 3 additions & 0 deletions .env.development
Original file line number Diff line number Diff line change
@@ -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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Remove spaces around the equal sign.

Environment variable assignments should not have spaces around the = operator, as some parsers may not handle them correctly.

Apply this diff:

-SKIP_OAUTH_PROXY = 1
+SKIP_OAUTH_PROXY=1
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
SKIP_OAUTH_PROXY = 1
SKIP_OAUTH_PROXY=1
🧰 Tools
🪛 dotenv-linter (4.0.0)

[warning] 4-4: [SpaceCharacter] The line has spaces around equal sign

(SpaceCharacter)

🤖 Prompt for AI Agents
.env.development lines 4-4: the environment variable assignment has spaces
around the '=' which can break some parsers; edit the line to remove the spaces
so it uses the canonical format SKIP_OAUTH_PROXY=1 (no spaces) and save the
file.

Comment on lines +3 to +4
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# Skip OAuth proxy for local development (proxy server may be unavailable)
SKIP_OAUTH_PROXY = 1

不要跳过代理,它是为了中国大多数电脑即使挂梯子也无法让 Node.js 稳定访问 GitHub OAuth 接口而架设的。只是从“闭源社”迁回时没部署我们自己的测试服,但现在我搞好了。

29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +41 to +42
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# API Host
NEXT_PUBLIC_API_HOST=https://openhackathon-service.onrender.com

公开变量在 .env(已被 Git 托管)


# 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`
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
- **Authorization callback URL**: `http://localhost:3000/login`
- **Authorization callback URL**: `http://localhost:3000`

写死子路径会让 sessionGuard 给任意页面提供 OAuth 登录的能力彻底废掉……

4. Copy the Client ID and generate a Client Secret

## Getting Started

First, run the development server:
Expand Down
14 changes: 12 additions & 2 deletions components/User/UserBar.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -9,33 +11,41 @@ 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 (
<>
<Button variant="success" href="/activity/create">
{t('create_hackathons')}
</Button>

{user && (
{user ? (
<Dropdown>
<Dropdown.Toggle>{showName}</Dropdown.Toggle>
<Dropdown.Menu>
<Dropdown.Item href="/me">{t('profile')}</Dropdown.Item>
<Dropdown.Item href={`/user/${user.id}`}>{t('home_page')}</Dropdown.Item>
Comment on lines +29 to 30
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这两项功能重复,请直接在原有用户详情页上改。

<Dropdown.Item
title={t('edit_profile_tips')}
target="_blank"
href="https://github.com/settings/profile"
onClick={() => sessionStore.signOut(true)}
>
{t('edit_profile')}
</Dropdown.Item>
<Dropdown.Divider />
<Dropdown.Item onClick={() => sessionStore.signOut(true)}>
{t('sign_out')}
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
) : (
<Button variant="outline-light" href={loginUrl}>
<Icon name="github" className="me-2" />
{t('sign_in')}
</Button>
)}
<LanguageMenu />
</>
Expand Down
191 changes: 191 additions & 0 deletions docs/TASKS.md
Original file line number Diff line number Diff line change
@@ -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)
32 changes: 24 additions & 8 deletions models/User/Session.ts
Original file line number Diff line number Diff line change
@@ -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;
}
});
Comment on lines +15 to +33
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

find . -type f -name "Session.ts" | head -20

Repository: FreeCodeCamp-Chengdu/HOP

Length of output: 92


🏁 Script executed:

git ls-files | grep -E "(login|auth)" | grep -E "\.(ts|tsx|js)$" | head -20

Repository: FreeCodeCamp-Chengdu/HOP

Length of output: 83


🏁 Script executed:

fd -e ts -e tsx | grep -i session | head -20

Repository: FreeCodeCamp-Chengdu/HOP

Length of output: 90


🏁 Script executed:

cat -n models/User/Session.ts

Repository: FreeCodeCamp-Chengdu/HOP

Length of output: 3357


🏁 Script executed:

cat -n pages/login.tsx

Repository: FreeCodeCamp-Chengdu/HOP

Length of output: 1385


🏁 Script executed:

rg -n "sessionGuard" pages/api/core.ts pages/api/core.js -A 20

Repository: FreeCodeCamp-Chengdu/HOP

Length of output: 254


🏁 Script executed:

fd -e ts -e js | xargs grep -l "sessionGuard" | head -10

Repository: FreeCodeCamp-Chengdu/HOP

Length of output: 85


🏁 Script executed:

cat -n pages/api/core.ts

Repository: FreeCodeCamp-Chengdu/HOP

Length of output: 4172


🏁 Script executed:

rg -n "ownClient|client\." pages/login.tsx

Repository: FreeCodeCamp-Chengdu/HOP

Length of output: 50


🏁 Script executed:

rg -n "fetch\|axios\|ownClient" pages/login.tsx pages/api/core.ts | grep -E "(fetch|axios|ownClient)"

Repository: FreeCodeCamp-Chengdu/HOP

Length of output: 50


🏁 Script executed:

rg -n "location.assign|location.href" --type ts --type tsx | head -20

Repository: FreeCodeCamp-Chengdu/HOP

Length of output: 95


🏁 Script executed:

rg -n "401|403" models/User/Session.ts pages/login.tsx pages/api/core.ts

Repository: FreeCodeCamp-Chengdu/HOP

Length of output: 141


The error re-throw after redirect is unnecessary but has minimal impact.

Line 31 throws the error even after initiating the redirect on line 27. Since location.assign() immediately begins navigation, the error propagates to calling code briefly before the page unloads. While this could cause transient console errors, the page navigation will complete and the error becomes moot. Consider not re-throwing the error after a successful redirect initiation.

Regarding infinite redirects: The login page is protected by server-side middleware (sessionGuard) in getServerSideProps and does not make client-side authenticated API calls, so it will not trigger the 401/403 error handler. This redirect scenario is not a concern for the login page specifically.

🤖 Prompt for AI Agents
In models/User/Session.ts around lines 15 to 33, the catch block re-throws the
HTTPError even after initiating a client-side redirect via
globalThis.location.assign(), which causes transient console errors; modify the
handler so that when status is 401 or 403 and a redirect is initiated (i.e.,
when not isServer()), do not re-throw the error — instead return or swallow the
error flow after calling location.assign(); in all other cases (not redirected
or non-401/403 errors) keep the current behavior and re-throw the error so
callers still receive exceptions.

Comment on lines +15 to +33
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. 只有 401 未登录状态才表示用户 JWT 失效,403 只是某个资源当前用户无权访问
  2. 所有客户端网络请求错误的兜底错误处理应在全局事件回调,网络中间件只处理纯数据逻辑,有何错误应由上层调用方判断,Axios 拦截器那种从底层截胡的思维要不得:

    HOP/pages/_app.tsx

    Lines 40 to 48 in b962bc8

    window.addEventListener('unhandledrejection', ({ reason }) => {
    const { message, response } = reason as HTTPError;
    const { statusText, body } = response || {};
    const tips = body?.message || statusText || message;
    if (tips) alert(tips);
    });
    }


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';
}
Expand Down
16 changes: 15 additions & 1 deletion pages/_app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -45,6 +46,19 @@ export default class CustomApp extends App<I18nProps> {

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();
}
Comment on lines +49 to +61
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

不要在每个页面上检测登录状态,会拖慢很多不需要登录态的公开页面。哪个页面需要,哪个页面自己检查登录态。

}

render() {
Expand Down
5 changes: 0 additions & 5 deletions pages/activity/create.module.less
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
18 changes: 12 additions & 6 deletions pages/activity/create.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,18 @@ const ActivityCreatePage: FC<JWTProps> = observer(() => {
</hgroup>
</section>

<section className="pb-5 bg-body-tertiary">
<Card className={classNames(styles['form-card'], 'border-0 mx-auto')}>
<Card.Body className="p-4 p-md-5">
<ActivityEditor />
</Card.Body>
</Card>
<section className="py-5 bg-body-tertiary">
<Container>
<Row className="justify-content-center">
<Col lg={10} xl={8}>
<Card className={classNames(styles['form-card'], 'border-0 rounded-4')}>
<Card.Body className="p-4 p-md-5">
<ActivityEditor />
</Card.Body>
</Card>
</Col>
</Row>
</Container>
</section>
</>
);
Expand Down
Loading