feat(project): init

This commit is contained in:
2026-02-03 17:33:52 +08:00
commit b57d04f172
32 changed files with 1057 additions and 0 deletions

6
.env.example Normal file
View File

@@ -0,0 +1,6 @@
# IAM_SERVICE_BASE_URLiam-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
View File

@@ -0,0 +1,7 @@
node_modules
.next
.env
.DS_Store
pnpm-lock.yaml

19
Dockerfile Normal file
View 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
View File

@@ -0,0 +1,19 @@
# iam-front
统一认证前端SSO 登录页),基于 Next.js 14App 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
View 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
View 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
View 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
View File

@@ -0,0 +1,7 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View 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;
}

View 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
}

View 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
}

View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,6 @@
import { redirect } from "next/navigation"
export default function Home() {
redirect("/login")
}

View 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">
clientIdtenantId 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;

View 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 }

View 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 }

View 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 }

View 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 }

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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"]
}