Skip to content
Merged
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
42 changes: 41 additions & 1 deletion .env.sample
Original file line number Diff line number Diff line change
@@ -1 +1,41 @@
GITHUB_TOKEN=github_pat_xxxxxxxxxxxxxx
# 本文件提供运行本项目所需的环境变量示例。
# 提交代码时请提交本文件而不是实际的 .env,真实密钥请存放在个人或 CI 配置中。

# NextAuth 基本配置
AUTH_URL=http://localhost:3000
# 生成 32 字节以上的随机字符串,可用 openssl: `openssl rand -base64 32`
AUTH_SECRET=
# GitHub OAuth App 的 Client ID / Secret,可在 GitHub Developer settings 中创建
AUTH_GITHUB_ID=
AUTH_GITHUB_SECRET=
# 可选:用于访问 GitHub API(例如同步仓库)
GITHUB_TOKEN=

# Neon 提供的 Postgres 连接。
# 登录 Neon 控制台 → 数据库 → "Connect" → "Connection details",可以复制以下所有变量。
# 推荐连接字符串
DATABASE_URL=
# 若需要跳过连接池器(pgBouncer),请使用 Neon 提供的 Unpooled 连接串
DATABASE_URL_UNPOOLED=

# 构建自定义连接时可以使用的参数,同样来自 Neon 的 "Connection details"
PGHOST=
PGHOST_UNPOOLED=
PGUSER=
PGDATABASE=
PGPASSWORD=

# Vercel Postgres 模板中常见的变量,Neon 也会同时提供。
POSTGRES_URL=
POSTGRES_URL_NON_POOLING=
POSTGRES_USER=
POSTGRES_HOST=
POSTGRES_PASSWORD=
POSTGRES_DATABASE=
POSTGRES_URL_NO_SSL=
POSTGRES_PRISMA_URL=

# 如果项目集成了 Neon 的 Stack Auth,需要在 Neon 控制台的 "Auth" 标签页中获取以下变量。
NEXT_PUBLIC_STACK_PROJECT_ID=
NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY=
STACK_SECRET_SERVER_KEY=
3 changes: 0 additions & 3 deletions .github/workflows/content-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,3 @@ jobs:

- name: Lint image references (non-blocking)
run: pnpm lint:images || echo "[warn] image lint found issues (non-blocking)"

# Build the site to validate MDX and docs using Fumadocs
- run: pnpm build
1 change: 0 additions & 1 deletion .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,3 @@ jobs:
- run: pnpm run lint
- run: pnpm run lint:images
- run: pnpm run typecheck
- run: pnpm run build
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,5 @@ Agents.md
.env

# VS code
.vscode
.vscode
/generated/prisma
17 changes: 17 additions & 0 deletions app/api/auth/[...nextauth]/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Login Flow Overview

这个目录承载站点的 NextAuth 路由处理。下面是登录流程与依赖的简要说明:

- **身份验证框架**:使用 [NextAuth.js 5 (Auth.js)](https://authjs.dev/) 作为认证核心,通过 `app/auth.ts` 暴露的 `handlers` 响应 GET/POST 请求。
- **OAuth provider**:接入 GitHub OAuth(`next-auth/providers/github`),读取用户的 `id/name/email/avatar` 并持久化到本地用户表。
- **数据库适配器**:优先使用 [@auth/neon-adapter](https://authjs.dev/reference/adapter/neon) 将用户、账户、会话数据写入 Neon Postgres。若运行环境缺少 `DATABASE_URL`,系统会回退到 JWT 会话策略,不再访问数据库,方便协作者在无 Neon 凭据的情况下开发。
- **会话策略**:有 Neon 配置时启用数据库会话(`strategy: "database"`),否则改用默认的 JWT 签名。
- **必要环境变量**:`AUTH_SECRET`/`NEXTAUTH_SECRET` 用于签名;`AUTH_GITHUB_ID`、`AUTH_GITHUB_SECRET` 用于 GitHub OAuth;`DATABASE_URL` 控制 Neon 连接(可选)。开发环境缺少这些变量时会给出控制台警告并使用安全兜底逻辑,以保证本地能跑通。

### 本地无 `.env` 的执行策略

- 如果 `.env` 没有配置,只要 GitHub 登录仍在,NextAuth 会使用内置的开发密钥和 JWT 会话继续工作,登录流程不会报错。
- Neon 数据库适配器会被自动禁用,此时用户信息只保存在 cookie/JWT 中,不会写入 `users / sessions` 表;适合纯前端协作者快速启动项目。
- 控制台会输出显式警告,提示缺少密钥或数据库连接,确保真正部署前补齐配置。

如需扩展更多 provider 或调整会话策略,可直接修改 `auth.config.ts` 与 `auth.ts`。
2 changes: 2 additions & 0 deletions app/api/auth/[...nextauth]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import { handlers } from "@/auth";
export const { GET, POST } = handlers;
8 changes: 7 additions & 1 deletion app/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,13 @@ import { ThemeToggle } from "./ThemeToggle";
import { Button } from "../../components/ui/button";
import { MessageCircle } from "lucide-react";
import { Github as GithubIcon } from "./icons/Github";
import { SignInButton } from "./SignInButton";
import { auth } from "@/auth";
import { UserMenu } from "./UserMenu";

export function Header() {
export async function Header() {
const session = await auth();
const user = session?.user;
return (
<header className="fixed top-0 w-full z-50 bg-background/80 backdrop-blur-lg border-b border-border">
<div className="container mx-auto px-6 h-16 flex items-center justify-between">
Expand Down Expand Up @@ -62,6 +67,7 @@ export function Header() {
</a>
</Button>
<ThemeToggle />
{user ? <UserMenu user={user} /> : <SignInButton />}
</div>
</div>
</header>
Expand Down
22 changes: 22 additions & 0 deletions app/components/SignInButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { signIn } from "@/auth";
import { Button } from "@/app/components/ui/button";

interface SignInButtonProps {
className?: string;
}

export function SignInButton({ className }: SignInButtonProps) {
return (
<form
className={className}
action={async () => {
"use server";
await signIn("github");
}}
>
<Button type="submit" size="sm" variant="outline">
Sign in with GitHub
</Button>
</form>
);
}
62 changes: 62 additions & 0 deletions app/components/UserMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { signOut } from "@/auth";
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@/app/components/ui/avatar";

interface UserMenuProps {
user: {
name?: string | null;
email?: string | null;
image?: string | null;
};
}

export function UserMenu({ user }: UserMenuProps) {
const initials = user.name?.[0] ?? user.email?.[0] ?? "?";

return (
<details className="relative inline-block text-left">
<summary
className="flex cursor-pointer list-none items-center rounded-full border border-border bg-background p-0.5 transition hover:border-primary/60 [&::-webkit-details-marker]:hidden"
aria-label="Account menu"
>
<Avatar className="size-9">
{user.image ? (
<AvatarImage src={user.image} alt={user.name ?? "User avatar"} />
) : (
<AvatarFallback>{initials}</AvatarFallback>
)}
</Avatar>
</summary>

<div className="absolute right-0 mt-2 w-60 overflow-hidden rounded-md border border-border bg-popover shadow-lg">
<div className="border-b border-border bg-muted/40 px-4 py-3">
<p className="text-sm font-medium text-foreground">
{user.name ?? "Signed in"}
</p>
{user.email ? (
<p className="text-xs text-muted-foreground" title={user.email}>
{user.email}
</p>
) : null}
</div>

<form
action={async () => {
"use server";
await signOut();
}}
>
<button
type="submit"
className="w-full px-4 py-2 text-left text-sm text-foreground transition hover:bg-muted"
>
Sign out
</button>
</form>
</div>
</details>
);
}
61 changes: 61 additions & 0 deletions auth.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import type { NextAuthConfig } from "next-auth";
import GitHub from "next-auth/providers/github";

// 在本地开发环境允许没有 .env 的协作者运行站点,因此先尝试读取两个常见的密钥变量,缺失时再使用内置的开发兜底值。
const envSecret = process.env.AUTH_SECRET ?? process.env.NEXTAUTH_SECRET;
const secret =
envSecret ??
(process.env.NODE_ENV !== "production"
? "__involutionhell_dev_secret__"
: undefined);

if (!envSecret && process.env.NODE_ENV !== "production") {
console.warn(
"[auth] AUTH_SECRET missing – using development fallback secret",
);
}

if (!secret) {
throw new Error("[auth] AUTH_SECRET is required in production environments");
}

export const authConfig = {
secret,
pages: {
signIn: "/login",
},
callbacks: {
authorized({ auth, request: { nextUrl } }) {
const isLoggedIn = !!auth?.user;
const isOnProtectedRoute = nextUrl.pathname.startsWith("/dashboard");

if (isOnProtectedRoute) {
if (isLoggedIn) return true;
return false;
}

return true;
},
async signIn() {
return true;
},
async session({ session }) {
return session;
},
async jwt({ token }) {
return token;
},
},
providers: [
GitHub({
profile(profile) {
return {
id: profile.id.toString(),
name: profile.name ?? profile.login,
email: profile.email,
image: profile.avatar_url,
};
},
}),
],
} satisfies NextAuthConfig;
51 changes: 51 additions & 0 deletions auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import NextAuth from "next-auth";
import { authConfig } from "./auth.config";
import GitHub from "next-auth/providers/github";
import { Pool } from "@neondatabase/serverless";
import NeonAdapter from "@auth/neon-adapter";

type NeonAdapterPool = Parameters<typeof NeonAdapter>[0];

export const { handlers, auth, signIn, signOut } = NextAuth(() => {
// Neon 连接只在有数据库配置时启用;本地协作者若没有 `.env`,将回退为纯 JWT 会话,避免直接抛错阻塞开发。
const databaseUrl = process.env.DATABASE_URL;
const adapter = databaseUrl
? NeonAdapter(
new Pool({
connectionString: databaseUrl,
}) as unknown as NeonAdapterPool,
)
: undefined;

if (!databaseUrl) {
console.warn("[auth] DATABASE_URL missing – running without Neon adapter");
}

return {
...authConfig,
providers: [
GitHub({
profile(profile) {
return {
id: profile.id.toString(), // 与数据库的整数主键兼容
name: profile.name ?? profile.login,
email: profile.email,
image: profile.avatar_url,
};
},
}),
],
...(adapter
? {
adapter,
session: {
strategy: "database" as const,
},
}
: {
session: {
strategy: "jwt" as const,
},
}),
};
});
6 changes: 6 additions & 0 deletions lib/layout.shared.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import type { BaseLayoutProps } from "fumadocs-ui/layouts/shared";
import { SignInButton } from "@/app/components/SignInButton";

export function baseOptions(): BaseLayoutProps {
return {
nav: {
title: "Involution Hell",
children: (
<div className="ms-auto flex justify-end">
<SignInButton />
</div>
),
},
};
}
9 changes: 9 additions & 0 deletions middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { auth } from "./auth";

export default auth;

export const config = {
// https://nextjs.org/docs/app/building-your-application/routing/middleware#matcher
matcher: ["/((?!api|_next/static|_next/image|.*\\.png$).*)"],
runtime: "nodejs",
};
1 change: 0 additions & 1 deletion next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ const withNextIntl = createNextIntlPlugin("./i18n.ts");
/** @type {import('next').NextConfig} */
const config = {
reactStrictMode: true,
images: { unoptimized: true }, // 避免使用 Next Image 优化服务
};

export default withNextIntl(withMDX(config));
Loading