feat(project): init
This commit is contained in:
6
.env.example
Normal file
6
.env.example
Normal file
@@ -0,0 +1,6 @@
|
||||
# IAM_SERVICE_BASE_URL:iam-service 的基础地址(iam-front 通过服务端路由调用它)
|
||||
IAM_SERVICE_BASE_URL=http://127.0.0.1:3000
|
||||
|
||||
# CAPTCHA_SECRET:验证码 cookie 的签名密钥(HMAC-SHA256)。
|
||||
# 作用:防止客户端篡改验证码 cookie;不是验证码本身,也不会对外返回。
|
||||
CAPTCHA_SECRET=please_replace_with_a_secure_random_string
|
||||
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
.next
|
||||
.env
|
||||
.DS_Store
|
||||
|
||||
pnpm-lock.yaml
|
||||
|
||||
19
Dockerfile
Normal file
19
Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
||||
FROM node:20-alpine AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
RUN npm install
|
||||
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=6020
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
CMD ["node", "server.js"]
|
||||
19
README.md
Normal file
19
README.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# iam-front
|
||||
|
||||
统一认证前端(SSO 登录页),基于 Next.js 14(App Router)+ TypeScript + Tailwind CSS + shadcn/ui。
|
||||
|
||||
## 本地启动
|
||||
|
||||
1. 复制环境变量:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
2. 安装依赖并启动:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
5
next-env.d.ts
vendored
Normal file
5
next-env.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
||||
40
next.config.js
Normal file
40
next.config.js
Normal file
@@ -0,0 +1,40 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: "standalone",
|
||||
async headers() {
|
||||
const isDev = process.env.NODE_ENV === "development";
|
||||
const csp = [
|
||||
"default-src 'self'",
|
||||
"base-uri 'self'",
|
||||
"frame-ancestors 'none'",
|
||||
"object-src 'none'",
|
||||
"form-action 'self'",
|
||||
"img-src 'self' data:",
|
||||
isDev
|
||||
? "script-src 'self' 'unsafe-eval' 'unsafe-inline'"
|
||||
: "script-src 'self'",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"connect-src 'self'",
|
||||
].join("; ");
|
||||
|
||||
return [
|
||||
{
|
||||
source: "/(.*)",
|
||||
headers: [
|
||||
{ key: "Content-Security-Policy", value: csp },
|
||||
{ key: "X-Frame-Options", value: "DENY" },
|
||||
{ key: "X-Content-Type-Options", value: "nosniff" },
|
||||
{ key: "Referrer-Policy", value: "no-referrer" },
|
||||
{
|
||||
key: "Strict-Transport-Security",
|
||||
value: "max-age=63072000; includeSubDomains; preload",
|
||||
},
|
||||
{ key: "Permissions-Policy", value: "geolocation=(), microphone=(), camera=()" },
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
|
||||
36
package.json
Normal file
36
package.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "iam-front",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"scripts": {
|
||||
"dev": "next dev -p 6020",
|
||||
"build": "next build",
|
||||
"start": "next start -p 6020",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-checkbox": "^1.3.0",
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-slot": "^1.2.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"jose": "^5.9.6",
|
||||
"lucide-react": "^0.468.0",
|
||||
"next": "^14.2.25",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"redis": "^4.7.0",
|
||||
"tailwind-merge": "^2.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.17.16",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-next": "^14.2.25",
|
||||
"postcss": "^8.5.1",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
7
postcss.config.js
Normal file
7
postcss.config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
||||
88
src/app/api/auth/login/route.ts
Normal file
88
src/app/api/auth/login/route.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { iamLoginCode } from "@/lib/iam";
|
||||
import { verifyCaptchaCookie } from "@/lib/captcha";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
type Body = {
|
||||
clientId?: string;
|
||||
tenantId?: string;
|
||||
callback?: string;
|
||||
email?: string;
|
||||
password?: string;
|
||||
captcha?: string;
|
||||
rememberMe?: boolean;
|
||||
};
|
||||
|
||||
function validateCallbackUrl(raw: string): URL {
|
||||
const u = new URL(raw);
|
||||
const isLocalhost = u.hostname === "localhost" || u.hostname === "127.0.0.1";
|
||||
const allowHttp = process.env.NODE_ENV !== "production" && isLocalhost;
|
||||
if (u.protocol !== "https:" && !allowHttp) {
|
||||
throw new Error("callback must be https");
|
||||
}
|
||||
return u;
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const body = (await req.json()) as Body;
|
||||
|
||||
const clientId = body.clientId?.trim() ?? "";
|
||||
const tenantId = body.tenantId?.trim() ?? "";
|
||||
const callback = body.callback?.trim() ?? "";
|
||||
const email = body.email?.trim() ?? "";
|
||||
const password = body.password ?? "";
|
||||
const captcha = body.captcha?.trim() ?? "";
|
||||
|
||||
if (!clientId || !tenantId || !callback || !email || !password) {
|
||||
return NextResponse.json(
|
||||
{ message: "missing required fields" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const captchaCookie = req.cookies.get("iam_captcha")?.value;
|
||||
if (!verifyCaptchaCookie(captchaCookie, captcha)) {
|
||||
return NextResponse.json({ message: "invalid captcha" }, { status: 400 });
|
||||
}
|
||||
|
||||
let callbackUrl: URL;
|
||||
try {
|
||||
callbackUrl = validateCallbackUrl(callback);
|
||||
} catch {
|
||||
return NextResponse.json({ message: "invalid callback" }, { status: 400 });
|
||||
}
|
||||
|
||||
const issued = await iamLoginCode({
|
||||
tenantId,
|
||||
clientId,
|
||||
redirectUri: callbackUrl.toString(),
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
const res = NextResponse.json(
|
||||
{
|
||||
redirectTo: issued.redirectTo,
|
||||
expiresAt: issued.expiresAt,
|
||||
},
|
||||
{ status: 200, headers: { "Cache-Control": "no-store" } },
|
||||
);
|
||||
|
||||
if (body.rememberMe) {
|
||||
res.cookies.set("iam_remember_email", email, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "strict",
|
||||
path: "/",
|
||||
maxAge: 30 * 24 * 60 * 60,
|
||||
});
|
||||
} else {
|
||||
res.cookies.delete("iam_remember_email");
|
||||
}
|
||||
|
||||
res.cookies.delete("iam_captcha");
|
||||
|
||||
return res;
|
||||
}
|
||||
23
src/app/api/auth/logout/route.ts
Normal file
23
src/app/api/auth/logout/route.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
|
||||
import { mustGetEnv } from "@/lib/env"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const auth = req.headers.get("authorization") ?? ""
|
||||
if (auth.toLowerCase().startsWith("bearer ")) {
|
||||
const base = mustGetEnv("IAM_SERVICE_BASE_URL").replace(/\/$/, "")
|
||||
const url = `${base}/auth/logout`
|
||||
await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { Authorization: auth },
|
||||
cache: "no-store",
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const res = NextResponse.json({ ok: true }, { headers: { "Cache-Control": "no-store" } })
|
||||
res.cookies.delete("iam_remember_email")
|
||||
res.cookies.delete("iam_captcha")
|
||||
return res
|
||||
}
|
||||
28
src/app/api/captcha/route.ts
Normal file
28
src/app/api/captcha/route.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { NextResponse } from "next/server"
|
||||
|
||||
import { captchaSvg, generateCaptcha, signCaptchaCookie } from "@/lib/captcha"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
|
||||
export async function GET() {
|
||||
const payload = generateCaptcha(120)
|
||||
const svg = captchaSvg(payload.code)
|
||||
|
||||
const res = new NextResponse(svg, {
|
||||
headers: {
|
||||
"Content-Type": "image/svg+xml",
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
})
|
||||
|
||||
res.cookies.set("iam_captcha", signCaptchaCookie(payload), {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: "strict",
|
||||
path: "/",
|
||||
maxAge: 120,
|
||||
})
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
10
src/app/forgot-password/page.tsx
Normal file
10
src/app/forgot-password/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
export default function ForgotPasswordPage() {
|
||||
return (
|
||||
<main className="min-h-screen flex items-center justify-center p-6">
|
||||
<div className="max-w-md text-sm">
|
||||
请联系租户管理员或通过业务系统的找回流程重置密码。
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
30
src/app/globals.css
Normal file
30
src/app/globals.css
Normal file
@@ -0,0 +1,30 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--primary: 222.2 47.4% 11.2%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 222.2 84% 4.9%;
|
||||
--radius: 0.75rem;
|
||||
}
|
||||
|
||||
body {
|
||||
background: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
16
src/app/layout.tsx
Normal file
16
src/app/layout.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { Metadata } from "next"
|
||||
import "./globals.css"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "IAM SSO",
|
||||
description: "Unified login for all services",
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="zh-CN">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
|
||||
25
src/app/login/page.tsx
Normal file
25
src/app/login/page.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { cookies } from "next/headers";
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
const LoginFormNoSSR = dynamic(() => import("@/components/login-form"), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
export default function LoginPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: { tenantId?: string; callback?: string; clientId?: string };
|
||||
}) {
|
||||
const rememberedEmail = cookies().get("iam_remember_email")?.value ?? "";
|
||||
|
||||
return (
|
||||
<main className="min-h-screen flex items-center justify-center p-6">
|
||||
<LoginFormNoSSR
|
||||
clientId={searchParams.clientId ?? ""}
|
||||
tenantId={searchParams.tenantId ?? ""}
|
||||
callback={searchParams.callback ?? ""}
|
||||
initialEmail={rememberedEmail}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
16
src/app/logout/page.tsx
Normal file
16
src/app/logout/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import Link from "next/link"
|
||||
|
||||
export default function LogoutPage() {
|
||||
return (
|
||||
<main className="min-h-screen flex items-center justify-center p-6">
|
||||
<div className="max-w-md space-y-4 text-center">
|
||||
<div className="text-xl font-semibold">已退出登录</div>
|
||||
<div className="text-sm text-muted-foreground">你可以关闭此页面,或重新登录。</div>
|
||||
<Link className="text-sm underline underline-offset-4" href="/login">
|
||||
返回登录页
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
6
src/app/page.tsx
Normal file
6
src/app/page.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
export default function Home() {
|
||||
redirect("/login")
|
||||
}
|
||||
|
||||
177
src/components/login-form.tsx
Normal file
177
src/components/login-form.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
const LoginForm = (props: {
|
||||
clientId: string;
|
||||
tenantId: string;
|
||||
callback: string;
|
||||
initialEmail: string;
|
||||
}) => {
|
||||
const [email, setEmail] = React.useState(props.initialEmail);
|
||||
const [password, setPassword] = React.useState("");
|
||||
const [captcha, setCaptcha] = React.useState("");
|
||||
const [rememberMe, setRememberMe] = React.useState(
|
||||
Boolean(props.initialEmail),
|
||||
);
|
||||
const [submitting, setSubmitting] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [captchaKey, setCaptchaKey] = React.useState(() => String(Date.now()));
|
||||
|
||||
const clientId = props.clientId;
|
||||
const tenantId = props.tenantId;
|
||||
const callback = props.callback;
|
||||
|
||||
async function onSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
clientId,
|
||||
tenantId,
|
||||
callback,
|
||||
email,
|
||||
password,
|
||||
captcha,
|
||||
rememberMe,
|
||||
}),
|
||||
});
|
||||
const json = (await res.json()) as {
|
||||
redirectTo?: string;
|
||||
message?: string;
|
||||
};
|
||||
if (!res.ok || !json.redirectTo) {
|
||||
throw new Error(json.message || "登录失败");
|
||||
}
|
||||
console.log(`json.redirectTo`, json.redirectTo);
|
||||
window.location.href = json.redirectTo;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "登录失败");
|
||||
setCaptcha("");
|
||||
setCaptchaKey(String(Date.now()));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const missingParams = !clientId || !tenantId || !callback;
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>统一登录</CardTitle>
|
||||
<CardDescription>使用 IAM 账号登录以访问业务系统</CardDescription>
|
||||
</CardHeader>
|
||||
<form onSubmit={onSubmit}>
|
||||
<CardContent className="space-y-4">
|
||||
{missingParams ? (
|
||||
<div className="text-sm text-destructive">
|
||||
缺少 clientId、tenantId 或 callback 参数,无法继续登录
|
||||
</div>
|
||||
) : null}
|
||||
{error ? (
|
||||
<div className="text-sm text-destructive">{error}</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">用户名</Label>
|
||||
<Input
|
||||
id="email"
|
||||
autoComplete="username"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="user@example.com"
|
||||
disabled={submitting}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">密码</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={submitting}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="captcha">验证码</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="captcha"
|
||||
inputMode="numeric"
|
||||
value={captcha}
|
||||
onChange={(e) => setCaptcha(e.target.value)}
|
||||
disabled={submitting}
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded-md border border-input bg-background px-2"
|
||||
onClick={() => setCaptchaKey(String(Date.now()))}
|
||||
aria-label="刷新验证码"
|
||||
>
|
||||
<img
|
||||
alt="captcha"
|
||||
src={`/api/captcha?key=${encodeURIComponent(captchaKey)}`}
|
||||
width={120}
|
||||
height={40}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={rememberMe}
|
||||
onCheckedChange={(v) => setRememberMe(Boolean(v))}
|
||||
/>
|
||||
记住我
|
||||
</label>
|
||||
<Link
|
||||
className="text-sm underline underline-offset-4"
|
||||
href={`/forgot-password?tenantId=${encodeURIComponent(tenantId)}`}
|
||||
>
|
||||
忘记密码
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex gap-2">
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={submitting || missingParams}
|
||||
>
|
||||
{submitting ? "登录中..." : "登录"}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginForm;
|
||||
52
src/components/ui/button.tsx
Normal file
52
src/components/ui/button.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:opacity-90",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:opacity-90",
|
||||
outline: "border border-input bg-background hover:bg-secondary",
|
||||
ghost: "hover:bg-secondary",
|
||||
destructive: "bg-destructive text-destructive-foreground hover:opacity-90",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
},
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
|
||||
52
src/components/ui/card.tsx
Normal file
52
src/components/ui/card.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("rounded-lg border border-border bg-card text-card-foreground shadow-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
)
|
||||
Card.displayName = "Card"
|
||||
|
||||
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
|
||||
),
|
||||
)
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h3 ref={ref} className={cn("text-2xl font-semibold leading-none tracking-tight", className)} {...props} />
|
||||
),
|
||||
)
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<p ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
|
||||
),
|
||||
)
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
),
|
||||
)
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
|
||||
),
|
||||
)
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
|
||||
27
src/components/ui/checkbox.tsx
Normal file
27
src/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { Check } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-input bg-background ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator className="flex items-center justify-center text-current">
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
))
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||
|
||||
export { Checkbox }
|
||||
|
||||
25
src/components/ui/input.tsx
Normal file
25
src/components/ui/input.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
},
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
|
||||
22
src/components/ui/label.tsx
Normal file
22
src/components/ui/label.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
||||
|
||||
36
src/lib/auth-code.ts
Normal file
36
src/lib/auth-code.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import crypto from "crypto"
|
||||
import { SignJWT } from "jose"
|
||||
|
||||
import { mustGetEnv } from "@/lib/env"
|
||||
|
||||
export type AccessTokenClaims = {
|
||||
sub?: string
|
||||
tenant_id?: string
|
||||
}
|
||||
|
||||
export function unsafeDecodeJwtPayload<T>(token: string): T {
|
||||
const parts = token.split(".")
|
||||
if (parts.length < 2) throw new Error("Invalid JWT")
|
||||
const payload = Buffer.from(parts[1], "base64url").toString("utf8")
|
||||
return JSON.parse(payload) as T
|
||||
}
|
||||
|
||||
export async function issueAuthCode(params: { userId: string; tenantId: string }): Promise<{
|
||||
code: string
|
||||
jti: string
|
||||
exp: number
|
||||
}> {
|
||||
const secret = new TextEncoder().encode(mustGetEnv("AUTH_CODE_JWT_SECRET"))
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const exp = now + 5 * 60
|
||||
const jti = crypto.randomUUID()
|
||||
const code = await new SignJWT({ tenant_id: params.tenantId, jti })
|
||||
.setProtectedHeader({ alg: "HS256", typ: "JWT" })
|
||||
.setIssuedAt(now)
|
||||
.setExpirationTime(exp)
|
||||
.setIssuer("iam-front")
|
||||
.setSubject(params.userId)
|
||||
.sign(secret)
|
||||
|
||||
return { code, jti, exp }
|
||||
}
|
||||
74
src/lib/captcha.ts
Normal file
74
src/lib/captcha.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import crypto from "crypto"
|
||||
|
||||
import { mustGetEnv } from "@/lib/env"
|
||||
|
||||
export type CaptchaPayload = {
|
||||
code: string
|
||||
exp: number
|
||||
nonce: string
|
||||
}
|
||||
|
||||
export function generateCaptcha(ttlSeconds: number): CaptchaPayload {
|
||||
const code = String(crypto.randomInt(1000, 9999))
|
||||
const exp = Math.floor(Date.now() / 1000) + ttlSeconds
|
||||
const nonce = crypto.randomBytes(12).toString("hex")
|
||||
return { code, exp, nonce }
|
||||
}
|
||||
|
||||
export function signCaptchaCookie(payload: CaptchaPayload): string {
|
||||
const data = `${payload.exp}.${payload.nonce}.${payload.code}`
|
||||
const mac = crypto
|
||||
.createHmac("sha256", mustGetEnv("CAPTCHA_SECRET"))
|
||||
.update(data)
|
||||
.digest("hex")
|
||||
return `${payload.exp}.${payload.nonce}.${mac}`
|
||||
}
|
||||
|
||||
export function verifyCaptchaCookie(cookieValue: string | undefined, userInput: string): boolean {
|
||||
if (!cookieValue) return false
|
||||
const parts = cookieValue.split(".")
|
||||
if (parts.length !== 3) return false
|
||||
|
||||
const exp = Number(parts[0])
|
||||
const nonce = parts[1]
|
||||
const mac = parts[2]
|
||||
if (!Number.isFinite(exp)) return false
|
||||
if (Math.floor(Date.now() / 1000) > exp) return false
|
||||
|
||||
const code = userInput.trim()
|
||||
if (!/^\d{4}$/.test(code)) return false
|
||||
|
||||
const data = `${exp}.${nonce}.${code}`
|
||||
const expected = crypto
|
||||
.createHmac("sha256", mustGetEnv("CAPTCHA_SECRET"))
|
||||
.update(data)
|
||||
.digest("hex")
|
||||
return crypto.timingSafeEqual(Buffer.from(mac), Buffer.from(expected))
|
||||
}
|
||||
|
||||
export function captchaSvg(code: string): string {
|
||||
const width = 120
|
||||
const height = 40
|
||||
const noise = Array.from({ length: 6 }).map(() => ({
|
||||
x1: crypto.randomInt(0, width),
|
||||
y1: crypto.randomInt(0, height),
|
||||
x2: crypto.randomInt(0, width),
|
||||
y2: crypto.randomInt(0, height),
|
||||
}))
|
||||
|
||||
const lines = noise
|
||||
.map(
|
||||
(l) =>
|
||||
`<line x1="${l.x1}" y1="${l.y1}" x2="${l.x2}" y2="${l.y2}" stroke="rgba(0,0,0,0.2)" stroke-width="1" />`,
|
||||
)
|
||||
.join("")
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
|
||||
<rect width="100%" height="100%" fill="#ffffff" />
|
||||
${lines}
|
||||
<text x="16" y="28" font-family="ui-sans-serif, system-ui, -apple-system" font-size="22" fill="#111827" letter-spacing="6">
|
||||
${code}
|
||||
</text>
|
||||
</svg>`
|
||||
}
|
||||
5
src/lib/env.ts
Normal file
5
src/lib/env.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export function mustGetEnv(name: string): string {
|
||||
const v = process.env[name]
|
||||
if (!v) throw new Error(`${name} is required`)
|
||||
return v
|
||||
}
|
||||
77
src/lib/iam.ts
Normal file
77
src/lib/iam.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { mustGetEnv } from "@/lib/env";
|
||||
|
||||
type AppResponse<T> = {
|
||||
code: number;
|
||||
message: string;
|
||||
data: T;
|
||||
trace_id?: string | null;
|
||||
details?: unknown;
|
||||
};
|
||||
|
||||
export type IamLoginResponse = {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
};
|
||||
|
||||
export type IamLoginCodeResponse = {
|
||||
redirectTo: string;
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
export async function iamLogin(params: {
|
||||
tenantId: string;
|
||||
email: string;
|
||||
password: string;
|
||||
}): Promise<IamLoginResponse> {
|
||||
const base = mustGetEnv("IAM_SERVICE_BASE_URL").replace(/\/$/, "");
|
||||
const url = `${base}/api/v1/auth/login`;
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Tenant-ID": params.tenantId,
|
||||
},
|
||||
body: JSON.stringify({ email: params.email, password: params.password }),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const body = (await res.json()) as AppResponse<IamLoginResponse>;
|
||||
if (!res.ok || body.code !== 0) {
|
||||
throw new Error(body.message || "Login failed");
|
||||
}
|
||||
return body.data;
|
||||
}
|
||||
|
||||
export async function iamLoginCode(params: {
|
||||
tenantId: string;
|
||||
clientId: string;
|
||||
redirectUri: string;
|
||||
email: string;
|
||||
password: string;
|
||||
}): Promise<IamLoginCodeResponse> {
|
||||
const base = mustGetEnv("IAM_SERVICE_BASE_URL").replace(/\/$/, "");
|
||||
const url = `${base}/api/v1/auth/login-code`;
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Tenant-ID": params.tenantId,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
clientId: params.clientId,
|
||||
redirectUri: params.redirectUri,
|
||||
email: params.email,
|
||||
password: params.password,
|
||||
}),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const body = (await res.json()) as AppResponse<IamLoginCodeResponse>;
|
||||
|
||||
if (!res.ok || body.code !== 0) {
|
||||
throw new Error(body.message || "Login failed");
|
||||
}
|
||||
return body.data;
|
||||
}
|
||||
22
src/lib/redis.ts
Normal file
22
src/lib/redis.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { createClient } from "redis"
|
||||
|
||||
import { mustGetEnv } from "@/lib/env"
|
||||
|
||||
type AnyRedisClient = ReturnType<typeof createClient>
|
||||
|
||||
let client: AnyRedisClient | null = null
|
||||
let connecting: Promise<AnyRedisClient> | null = null
|
||||
|
||||
export async function getRedis(): Promise<AnyRedisClient> {
|
||||
if (client) return client
|
||||
if (connecting) return connecting
|
||||
|
||||
connecting = (async () => {
|
||||
const c = createClient({ url: mustGetEnv("REDIS_URL") })
|
||||
await c.connect()
|
||||
client = c
|
||||
return c
|
||||
})()
|
||||
|
||||
return connecting
|
||||
}
|
||||
7
src/lib/utils.ts
Normal file
7
src/lib/utils.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
18
src/middleware.ts
Normal file
18
src/middleware.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
|
||||
export function middleware(req: NextRequest) {
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
const proto = req.headers.get("x-forwarded-proto")
|
||||
if (proto && proto !== "https") {
|
||||
const url = req.nextUrl.clone()
|
||||
url.protocol = "https"
|
||||
return NextResponse.redirect(url, 308)
|
||||
}
|
||||
}
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
|
||||
}
|
||||
|
||||
57
tailwind.config.ts
Normal file
57
tailwind.config.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { Config } from "tailwindcss"
|
||||
|
||||
const config: Config = {
|
||||
darkMode: ["class"],
|
||||
content: ["./src/**/*.{ts,tsx}"],
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
padding: "2rem",
|
||||
screens: {
|
||||
"2xl": "1400px",
|
||||
},
|
||||
},
|
||||
extend: {
|
||||
colors: {
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
||||
export default config
|
||||
|
||||
25
tsconfig.json
Normal file
25
tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["dom", "dom.iterable", "es2022"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user