perf(prettier): perf

This commit is contained in:
2026-02-11 13:57:47 +08:00
parent 2696ec8692
commit 03a1e6043d
21 changed files with 354 additions and 285 deletions

View File

@@ -7,7 +7,8 @@
"build": "next build", "build": "next build",
"start": "next start -p 6020", "start": "next start -p 6020",
"lint": "eslint .", "lint": "eslint .",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit",
"format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css,scss,md}\""
}, },
"dependencies": { "dependencies": {
"@radix-ui/react-checkbox": "^1.3.0", "@radix-ui/react-checkbox": "^1.3.0",

View File

@@ -64,8 +64,8 @@ export async function POST(req: NextRequest) {
const res = NextResponse.json( const res = NextResponse.json(
{ {
redirectTo: issued.redirectTo, redirectTo: issued.redirect_to,
expiresAt: issued.expiresAt, expiresAt: issued.expires_at,
}, },
{ status: 200, headers: { "Cache-Control": "no-store" } }, { status: 200, headers: { "Cache-Control": "no-store" } },
); );

View File

@@ -1,23 +1,26 @@
import { NextRequest, NextResponse } from "next/server" import { NextRequest, NextResponse } from "next/server";
import { mustGetEnv } from "@/lib/env" import { mustGetEnv } from "@/lib/env";
export const runtime = "nodejs" export const runtime = "nodejs";
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
const auth = req.headers.get("authorization") ?? "" const auth = req.headers.get("authorization") ?? "";
if (auth.toLowerCase().startsWith("bearer ")) { if (auth.toLowerCase().startsWith("bearer ")) {
const base = mustGetEnv("IAM_SERVICE_BASE_URL").replace(/\/$/, "") const base = mustGetEnv("IAM_SERVICE_BASE_URL").replace(/\/$/, "");
const url = `${base}/auth/logout` const url = `${base}/auth/logout`;
await fetch(url, { await fetch(url, {
method: "POST", method: "POST",
headers: { Authorization: auth }, headers: { Authorization: auth },
cache: "no-store", cache: "no-store",
}).catch(() => {}) }).catch(() => {});
} }
const res = NextResponse.json({ ok: true }, { headers: { "Cache-Control": "no-store" } }) const res = NextResponse.json(
res.cookies.delete("iam_remember_email") { ok: true },
res.cookies.delete("iam_captcha") { headers: { "Cache-Control": "no-store" } },
return res );
res.cookies.delete("iam_remember_email");
res.cookies.delete("iam_captcha");
return res;
} }

View File

@@ -1,19 +1,19 @@
import { NextResponse } from "next/server" import { NextResponse } from "next/server";
import { captchaSvg, generateCaptcha, signCaptchaCookie } from "@/lib/captcha" import { captchaSvg, generateCaptcha, signCaptchaCookie } from "@/lib/captcha";
export const runtime = "nodejs" export const runtime = "nodejs";
export async function GET() { export async function GET() {
const payload = generateCaptcha(120) const payload = generateCaptcha(120);
const svg = captchaSvg(payload.code) const svg = captchaSvg(payload.code);
const res = new NextResponse(svg, { const res = new NextResponse(svg, {
headers: { headers: {
"Content-Type": "image/svg+xml", "Content-Type": "image/svg+xml",
"Cache-Control": "no-store", "Cache-Control": "no-store",
}, },
}) });
res.cookies.set("iam_captcha", signCaptchaCookie(payload), { res.cookies.set("iam_captcha", signCaptchaCookie(payload), {
httpOnly: true, httpOnly: true,
@@ -21,8 +21,7 @@ export async function GET() {
sameSite: "strict", sameSite: "strict",
path: "/", path: "/",
maxAge: 120, maxAge: 120,
}) });
return res return res;
} }

View File

@@ -5,6 +5,5 @@ export default function ForgotPasswordPage() {
</div> </div>
</main> </main>
) );
} }

View File

@@ -1,16 +1,19 @@
import type { Metadata } from "next" import type { Metadata } from "next";
import "./globals.css" import "./globals.css";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "IAM SSO", title: "IAM SSO",
description: "Unified login for all services", description: "Unified login for all services",
} };
export default function RootLayout({ children }: { children: React.ReactNode }) { export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return ( return (
<html lang="zh-CN"> <html lang="zh-CN">
<body>{children}</body> <body>{children}</body>
</html> </html>
) );
} }

View File

@@ -1,16 +1,17 @@
import Link from "next/link" import Link from "next/link";
export default function LogoutPage() { export default function LogoutPage() {
return ( return (
<main className="min-h-screen flex items-center justify-center p-6"> <main className="min-h-screen flex items-center justify-center p-6">
<div className="max-w-md space-y-4 text-center"> <div className="max-w-md space-y-4 text-center">
<div className="text-xl font-semibold">退</div> <div className="text-xl font-semibold">退</div>
<div className="text-sm text-muted-foreground"></div> <div className="text-sm text-muted-foreground">
</div>
<Link className="text-sm underline underline-offset-4" href="/login"> <Link className="text-sm underline underline-offset-4" href="/login">
</Link> </Link>
</div> </div>
</main> </main>
) );
} }

View File

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

View File

@@ -1,50 +1,52 @@
import * as React from "react" import * as React from "react";
import Link from "next/link" import Link from "next/link";
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox" import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label";
import { CaptchaField } from "@/components/auth/captcha-field" import { CaptchaField } from "@/components/auth/captcha-field";
export function LoginForm(props: { export function LoginForm(props: {
clientId: string clientId: string;
tenantId: string tenantId: string;
callback: string callback: string;
initialEmail: string initialEmail: string;
prefillPassword?: string prefillPassword?: string;
disabled?: boolean disabled?: boolean;
externalError?: string | null externalError?: string | null;
onClearExternalError?: () => void onClearExternalError?: () => void;
captcha: string captcha: string;
onCaptchaChange: (next: string) => void onCaptchaChange: (next: string) => void;
captchaKey: string captchaKey: string;
onRefreshCaptcha: () => void onRefreshCaptcha: () => void;
}) { }) {
const [email, setEmail] = React.useState(props.initialEmail) const [email, setEmail] = React.useState(props.initialEmail);
const [password, setPassword] = React.useState("") const [password, setPassword] = React.useState("");
const [rememberMe, setRememberMe] = React.useState(Boolean(props.initialEmail)) const [rememberMe, setRememberMe] = React.useState(
const [submitting, setSubmitting] = React.useState(false) Boolean(props.initialEmail),
const [error, setError] = React.useState<string | null>(null) );
const [submitting, setSubmitting] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
React.useEffect(() => { React.useEffect(() => {
setEmail(props.initialEmail) setEmail(props.initialEmail);
setRememberMe(Boolean(props.initialEmail)) setRememberMe(Boolean(props.initialEmail));
}, [props.initialEmail]) }, [props.initialEmail]);
React.useEffect(() => { React.useEffect(() => {
if (props.prefillPassword) { if (props.prefillPassword) {
setPassword(props.prefillPassword) setPassword(props.prefillPassword);
} }
}, [props.prefillPassword]) }, [props.prefillPassword]);
const missingParams = !props.clientId || !props.tenantId || !props.callback const missingParams = !props.clientId || !props.tenantId || !props.callback;
async function onSubmit(e: React.FormEvent) { async function onSubmit(e: React.FormEvent) {
e.preventDefault() e.preventDefault();
setError(null) setError(null);
props.onClearExternalError?.() props.onClearExternalError?.();
setSubmitting(true) setSubmitting(true);
try { try {
const res = await fetch("/api/auth/login", { const res = await fetch("/api/auth/login", {
method: "POST", method: "POST",
@@ -58,22 +60,25 @@ export function LoginForm(props: {
captcha: props.captcha, captcha: props.captcha,
rememberMe, rememberMe,
}), }),
}) });
const json = (await res.json()) as { redirectTo?: string; message?: string } const json = (await res.json()) as {
redirectTo?: string;
message?: string;
};
if (!res.ok || !json.redirectTo) { if (!res.ok || !json.redirectTo) {
throw new Error(json.message || "登录失败") throw new Error(json.message || "登录失败");
} }
window.location.href = json.redirectTo window.location.href = json.redirectTo;
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "登录失败") setError(err instanceof Error ? err.message : "登录失败");
props.onCaptchaChange("") props.onCaptchaChange("");
props.onRefreshCaptcha() props.onRefreshCaptcha();
} finally { } finally {
setSubmitting(false) setSubmitting(false);
} }
} }
const disabled = Boolean(props.disabled) || submitting const disabled = Boolean(props.disabled) || submitting;
return ( return (
<form onSubmit={onSubmit} className="space-y-4"> <form onSubmit={onSubmit} className="space-y-4">
@@ -146,5 +151,5 @@ export function LoginForm(props: {
{submitting ? "登录中..." : "登录"} {submitting ? "登录中..." : "登录"}
</Button> </Button>
</form> </form>
) );
} }

View File

@@ -1,54 +1,60 @@
import * as React from "react" import * as React from "react";
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label";
import { CaptchaField } from "@/components/auth/captcha-field" import { CaptchaField } from "@/components/auth/captcha-field";
export function RegisterForm(props: { export function RegisterForm(props: {
tenantId: string tenantId: string;
disabled?: boolean disabled?: boolean;
captcha: string captcha: string;
onCaptchaChange: (next: string) => void onCaptchaChange: (next: string) => void;
captchaKey: string captchaKey: string;
onRefreshCaptcha: () => void onRefreshCaptcha: () => void;
onRegisterSuccessPrefillLogin: (payload: { email: string; password: string }) => void onRegisterSuccessPrefillLogin: (payload: {
onLoginAfterRegister: (payload: { email: string; password: string }) => Promise<void> email: string;
password: string;
}) => void;
onLoginAfterRegister: (payload: {
email: string;
password: string;
}) => Promise<void>;
}) { }) {
const [email, setEmail] = React.useState("") const [email, setEmail] = React.useState("");
const [password, setPassword] = React.useState("") const [password, setPassword] = React.useState("");
const [confirmPassword, setConfirmPassword] = React.useState("") const [confirmPassword, setConfirmPassword] = React.useState("");
const [submitting, setSubmitting] = React.useState(false) const [submitting, setSubmitting] = React.useState(false);
const [error, setError] = React.useState<string | null>(null) const [error, setError] = React.useState<string | null>(null);
const passwordMismatch = const passwordMismatch =
confirmPassword.length > 0 && password !== confirmPassword confirmPassword.length > 0 && password !== confirmPassword;
const missingTenant = !props.tenantId const missingTenant = !props.tenantId;
async function onSubmit(e: React.FormEvent) { async function onSubmit(e: React.FormEvent) {
e.preventDefault() e.preventDefault();
setError(null) setError(null);
const nextEmail = email.trim() const nextEmail = email.trim();
if (!props.tenantId) { if (!props.tenantId) {
setError("缺少 tenantId 参数,无法注册") setError("缺少 tenantId 参数,无法注册");
return return;
} }
if (!nextEmail || !password || !confirmPassword) { if (!nextEmail || !password || !confirmPassword) {
setError("请填写邮箱与密码") setError("请填写邮箱与密码");
return return;
} }
if (passwordMismatch) { if (passwordMismatch) {
setError("两次输入的密码不一致") setError("两次输入的密码不一致");
return return;
} }
if (!props.captcha.trim()) { if (!props.captcha.trim()) {
setError("请填写验证码") setError("请填写验证码");
return return;
} }
setSubmitting(true) setSubmitting(true);
try { try {
const res = await fetch("/api/auth/register", { const res = await fetch("/api/auth/register", {
method: "POST", method: "POST",
@@ -58,31 +64,31 @@ export function RegisterForm(props: {
email: nextEmail, email: nextEmail,
password, password,
}), }),
}) });
const json = (await res.json()) as { message?: string; id?: string } const json = (await res.json()) as { message?: string; id?: string };
if (!res.ok) { if (!res.ok) {
if (res.status === 409) { if (res.status === 409) {
throw new Error("该邮箱已注册,请直接登录") throw new Error("该邮箱已注册,请直接登录");
} }
throw new Error(json.message || "注册失败") throw new Error(json.message || "注册失败");
} }
if (!json.id) { if (!json.id) {
throw new Error("注册失败") throw new Error("注册失败");
} }
props.onRegisterSuccessPrefillLogin({ email: nextEmail, password }) props.onRegisterSuccessPrefillLogin({ email: nextEmail, password });
await props.onLoginAfterRegister({ email: nextEmail, password }) await props.onLoginAfterRegister({ email: nextEmail, password });
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "注册失败") setError(err instanceof Error ? err.message : "注册失败");
props.onCaptchaChange("") props.onCaptchaChange("");
props.onRefreshCaptcha() props.onRefreshCaptcha();
} finally { } finally {
setSubmitting(false) setSubmitting(false);
} }
} }
const disabled = Boolean(props.disabled) || submitting const disabled = Boolean(props.disabled) || submitting;
return ( return (
<form onSubmit={onSubmit} className="space-y-4"> <form onSubmit={onSubmit} className="space-y-4">
@@ -151,5 +157,5 @@ export function RegisterForm(props: {
{submitting ? "注册中..." : "注册并登录"} {submitting ? "注册中..." : "注册并登录"}
</Button> </Button>
</form> </form>
) );
} }

View File

@@ -1,8 +1,8 @@
import * as React from "react" import * as React from "react";
import { Slot } from "@radix-ui/react-slot" import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-base 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", "inline-flex items-center justify-center whitespace-nowrap rounded-md text-base 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",
@@ -13,7 +13,8 @@ const buttonVariants = cva(
secondary: "bg-secondary text-secondary-foreground hover:opacity-90", secondary: "bg-secondary text-secondary-foreground hover:opacity-90",
outline: "border border-input bg-background hover:bg-secondary", outline: "border border-input bg-background hover:bg-secondary",
ghost: "hover:bg-secondary", ghost: "hover:bg-secondary",
destructive: "bg-destructive text-destructive-foreground hover:opacity-90", destructive:
"bg-destructive text-destructive-foreground hover:opacity-90",
}, },
size: { size: {
default: "h-10 px-4 py-2", default: "h-10 px-4 py-2",
@@ -26,27 +27,27 @@ const buttonVariants = cva(
size: "default", size: "default",
}, },
}, },
) );
export interface ButtonProps export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>, extends
React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> { VariantProps<typeof buttonVariants> {
asChild?: boolean asChild?: boolean;
} }
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => { ({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button" const Comp = asChild ? Slot : "button";
return ( return (
<Comp <Comp
className={cn(buttonVariants({ variant, size, className }))} className={cn(buttonVariants({ variant, size, className }))}
ref={ref} ref={ref}
{...props} {...props}
/> />
) );
}, },
) );
Button.displayName = "Button" Button.displayName = "Button";
export { Button, buttonVariants }
export { Button, buttonVariants };

View File

@@ -1,52 +1,86 @@
import * as React from "react" import * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( const Card = React.forwardRef<
({ className, ...props }, ref) => ( HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div <div
ref={ref} ref={ref}
className={cn("rounded-lg border border-border bg-card text-card-foreground shadow-sm", className)} className={cn(
"rounded-lg border border-border bg-card text-card-foreground shadow-sm",
className,
)}
{...props} {...props}
/> />
), ));
) Card.displayName = "Card";
Card.displayName = "Card"
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( const CardHeader = React.forwardRef<
({ className, ...props }, ref) => ( HTMLDivElement,
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} /> React.HTMLAttributes<HTMLDivElement>
), >(({ className, ...props }, ref) => (
) <div
CardHeader.displayName = "CardHeader" 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>>( const CardTitle = React.forwardRef<
({ className, ...props }, ref) => ( HTMLHeadingElement,
<h3 ref={ref} className={cn("text-2xl font-semibold leading-none tracking-tight", className)} {...props} /> React.HTMLAttributes<HTMLHeadingElement>
), >(({ className, ...props }, ref) => (
) <h3
CardTitle.displayName = "CardTitle" 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>>( const CardDescription = React.forwardRef<
({ className, ...props }, ref) => ( HTMLParagraphElement,
<p ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} /> React.HTMLAttributes<HTMLParagraphElement>
), >(({ className, ...props }, ref) => (
) <p
CardDescription.displayName = "CardDescription" ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( const CardContent = React.forwardRef<
({ className, ...props }, ref) => ( HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} /> <div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
), ));
) CardContent.displayName = "CardContent";
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( const CardFooter = React.forwardRef<
({ className, ...props }, ref) => ( HTMLDivElement,
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} /> React.HTMLAttributes<HTMLDivElement>
), >(({ className, ...props }, ref) => (
) <div
CardFooter.displayName = "CardFooter" ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } {...props}
/>
));
CardFooter.displayName = "CardFooter";
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardDescription,
CardContent,
};

View File

@@ -1,8 +1,8 @@
import * as React from "react" import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox" import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { Check } from "lucide-react" import { Check } from "lucide-react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const Checkbox = React.forwardRef< const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>, React.ElementRef<typeof CheckboxPrimitive.Root>,
@@ -20,8 +20,7 @@ const Checkbox = React.forwardRef<
<Check className="h-3.5 w-3.5" /> <Check className="h-3.5 w-3.5" />
</CheckboxPrimitive.Indicator> </CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root> </CheckboxPrimitive.Root>
)) ));
Checkbox.displayName = CheckboxPrimitive.Root.displayName Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox }
export { Checkbox };

View File

@@ -1,7 +1,7 @@
import * as React from "react" import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label" import * as LabelPrimitive from "@radix-ui/react-label";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const Label = React.forwardRef< const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>, React.ElementRef<typeof LabelPrimitive.Root>,
@@ -15,8 +15,7 @@ const Label = React.forwardRef<
)} )}
{...props} {...props}
/> />
)) ));
Label.displayName = LabelPrimitive.Root.displayName Label.displayName = LabelPrimitive.Root.displayName;
export { Label }
export { Label };

View File

@@ -1,9 +1,9 @@
import * as React from "react" import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs" import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const Tabs = TabsPrimitive.Root const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef< const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>, React.ElementRef<typeof TabsPrimitive.List>,
@@ -17,8 +17,8 @@ const TabsList = React.forwardRef<
)} )}
{...props} {...props}
/> />
)) ));
TabsList.displayName = TabsPrimitive.List.displayName TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef< const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>, React.ElementRef<typeof TabsPrimitive.Trigger>,
@@ -32,8 +32,8 @@ const TabsTrigger = React.forwardRef<
)} )}
{...props} {...props}
/> />
)) ));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef< const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>, React.ElementRef<typeof TabsPrimitive.Content>,
@@ -41,10 +41,13 @@ const TabsContent = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<TabsPrimitive.Content <TabsPrimitive.Content
ref={ref} ref={ref}
className={cn("mt-4 ring-offset-background focus-visible:outline-none", className)} className={cn(
"mt-4 ring-offset-background focus-visible:outline-none",
className,
)}
{...props} {...props}
/> />
)) ));
TabsContent.displayName = TabsPrimitive.Content.displayName TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent } export { Tabs, TabsList, TabsTrigger, TabsContent };

View File

@@ -1,36 +1,39 @@
import crypto from "crypto" import crypto from "crypto";
import { SignJWT } from "jose" import { SignJWT } from "jose";
import { mustGetEnv } from "@/lib/env" import { mustGetEnv } from "@/lib/env";
export type AccessTokenClaims = { export type AccessTokenClaims = {
sub?: string sub?: string;
tenant_id?: string tenant_id?: string;
} };
export function unsafeDecodeJwtPayload<T>(token: string): T { export function unsafeDecodeJwtPayload<T>(token: string): T {
const parts = token.split(".") const parts = token.split(".");
if (parts.length < 2) throw new Error("Invalid JWT") if (parts.length < 2) throw new Error("Invalid JWT");
const payload = Buffer.from(parts[1], "base64url").toString("utf8") const payload = Buffer.from(parts[1], "base64url").toString("utf8");
return JSON.parse(payload) as T return JSON.parse(payload) as T;
} }
export async function issueAuthCode(params: { userId: string; tenantId: string }): Promise<{ export async function issueAuthCode(params: {
code: string userId: string;
jti: string tenantId: string;
exp: number }): Promise<{
code: string;
jti: string;
exp: number;
}> { }> {
const secret = new TextEncoder().encode(mustGetEnv("AUTH_CODE_JWT_SECRET")) const secret = new TextEncoder().encode(mustGetEnv("AUTH_CODE_JWT_SECRET"));
const now = Math.floor(Date.now() / 1000) const now = Math.floor(Date.now() / 1000);
const exp = now + 5 * 60 const exp = now + 5 * 60;
const jti = crypto.randomUUID() const jti = crypto.randomUUID();
const code = await new SignJWT({ tenant_id: params.tenantId, jti }) const code = await new SignJWT({ tenant_id: params.tenantId, jti })
.setProtectedHeader({ alg: "HS256", typ: "JWT" }) .setProtectedHeader({ alg: "HS256", typ: "JWT" })
.setIssuedAt(now) .setIssuedAt(now)
.setExpirationTime(exp) .setExpirationTime(exp)
.setIssuer("iam-front") .setIssuer("iam-front")
.setSubject(params.userId) .setSubject(params.userId)
.sign(secret) .sign(secret);
return { code, jti, exp } return { code, jti, exp };
} }

View File

@@ -1,67 +1,70 @@
import crypto from "crypto" import crypto from "crypto";
import { mustGetEnv } from "@/lib/env" import { mustGetEnv } from "@/lib/env";
export type CaptchaPayload = { export type CaptchaPayload = {
code: string code: string;
exp: number exp: number;
nonce: string nonce: string;
} };
export function generateCaptcha(ttlSeconds: number): CaptchaPayload { export function generateCaptcha(ttlSeconds: number): CaptchaPayload {
const code = String(crypto.randomInt(1000, 9999)) const code = String(crypto.randomInt(1000, 9999));
const exp = Math.floor(Date.now() / 1000) + ttlSeconds const exp = Math.floor(Date.now() / 1000) + ttlSeconds;
const nonce = crypto.randomBytes(12).toString("hex") const nonce = crypto.randomBytes(12).toString("hex");
return { code, exp, nonce } return { code, exp, nonce };
} }
export function signCaptchaCookie(payload: CaptchaPayload): string { export function signCaptchaCookie(payload: CaptchaPayload): string {
const data = `${payload.exp}.${payload.nonce}.${payload.code}` const data = `${payload.exp}.${payload.nonce}.${payload.code}`;
const mac = crypto const mac = crypto
.createHmac("sha256", mustGetEnv("CAPTCHA_SECRET")) .createHmac("sha256", mustGetEnv("CAPTCHA_SECRET"))
.update(data) .update(data)
.digest("hex") .digest("hex");
return `${payload.exp}.${payload.nonce}.${mac}` return `${payload.exp}.${payload.nonce}.${mac}`;
} }
export function verifyCaptchaCookie(cookieValue: string | undefined, userInput: string): boolean { export function verifyCaptchaCookie(
if (!cookieValue) return false cookieValue: string | undefined,
const parts = cookieValue.split(".") userInput: string,
if (parts.length !== 3) return false ): boolean {
if (!cookieValue) return false;
const parts = cookieValue.split(".");
if (parts.length !== 3) return false;
const exp = Number(parts[0]) const exp = Number(parts[0]);
const nonce = parts[1] const nonce = parts[1];
const mac = parts[2] const mac = parts[2];
if (!Number.isFinite(exp)) return false if (!Number.isFinite(exp)) return false;
if (Math.floor(Date.now() / 1000) > exp) return false if (Math.floor(Date.now() / 1000) > exp) return false;
const code = userInput.trim() const code = userInput.trim();
if (!/^\d{4}$/.test(code)) return false if (!/^\d{4}$/.test(code)) return false;
const data = `${exp}.${nonce}.${code}` const data = `${exp}.${nonce}.${code}`;
const expected = crypto const expected = crypto
.createHmac("sha256", mustGetEnv("CAPTCHA_SECRET")) .createHmac("sha256", mustGetEnv("CAPTCHA_SECRET"))
.update(data) .update(data)
.digest("hex") .digest("hex");
return crypto.timingSafeEqual(Buffer.from(mac), Buffer.from(expected)) return crypto.timingSafeEqual(Buffer.from(mac), Buffer.from(expected));
} }
export function captchaSvg(code: string): string { export function captchaSvg(code: string): string {
const width = 120 const width = 120;
const height = 40 const height = 40;
const noise = Array.from({ length: 6 }).map(() => ({ const noise = Array.from({ length: 6 }).map(() => ({
x1: crypto.randomInt(0, width), x1: crypto.randomInt(0, width),
y1: crypto.randomInt(0, height), y1: crypto.randomInt(0, height),
x2: crypto.randomInt(0, width), x2: crypto.randomInt(0, width),
y2: crypto.randomInt(0, height), y2: crypto.randomInt(0, height),
})) }));
const lines = noise const lines = noise
.map( .map(
(l) => (l) =>
`<line x1="${l.x1}" y1="${l.y1}" x2="${l.x2}" y2="${l.y2}" stroke="rgba(0,0,0,0.2)" stroke-width="1" />`, `<line x1="${l.x1}" y1="${l.y1}" x2="${l.x2}" y2="${l.y2}" stroke="rgba(0,0,0,0.2)" stroke-width="1" />`,
) )
.join("") .join("");
return `<?xml version="1.0" encoding="UTF-8"?> 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}"> <svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
@@ -70,5 +73,5 @@ export function captchaSvg(code: string): string {
<text x="16" y="28" font-family="ui-sans-serif, system-ui, -apple-system" font-size="22" fill="#111827" letter-spacing="6"> <text x="16" y="28" font-family="ui-sans-serif, system-ui, -apple-system" font-size="22" fill="#111827" letter-spacing="6">
${code} ${code}
</text> </text>
</svg>` </svg>`;
} }

View File

@@ -1,5 +1,5 @@
export function mustGetEnv(name: string): string { export function mustGetEnv(name: string): string {
const v = process.env[name] const v = process.env[name];
if (!v) throw new Error(`${name} is required`) if (!v) throw new Error(`${name} is required`);
return v return v;
} }

View File

@@ -26,8 +26,8 @@ export type IamLoginResponse = {
}; };
export type IamLoginCodeResponse = { export type IamLoginCodeResponse = {
redirectTo: string; redirect_to: string;
expiresAt: number; expires_at: number;
}; };
export type IamRegisterResponse = { export type IamRegisterResponse = {
@@ -54,7 +54,11 @@ export async function iamLogin(params: {
const body = (await res.json()) as AppResponse<IamLoginResponse>; const body = (await res.json()) as AppResponse<IamLoginResponse>;
if (!res.ok || body.code !== 0) { if (!res.ok || body.code !== 0) {
throw new IamApiError(body.message || "Login failed", res.status, body.code); throw new IamApiError(
body.message || "Login failed",
res.status,
body.code,
);
} }
return body.data; return body.data;
} }
@@ -78,7 +82,11 @@ export async function iamRegister(params: {
const body = (await res.json()) as AppResponse<IamRegisterResponse>; const body = (await res.json()) as AppResponse<IamRegisterResponse>;
if (!res.ok || body.code !== 0) { if (!res.ok || body.code !== 0) {
throw new IamApiError(body.message || "Register failed", res.status, body.code); throw new IamApiError(
body.message || "Register failed",
res.status,
body.code,
);
} }
return body.data; return body.data;
} }
@@ -99,8 +107,8 @@ export async function iamLoginCode(params: {
"X-Tenant-ID": params.tenantId, "X-Tenant-ID": params.tenantId,
}, },
body: JSON.stringify({ body: JSON.stringify({
clientId: params.clientId, client_id: params.clientId,
redirectUri: params.redirectUri, redirect_uri: params.redirectUri,
email: params.email, email: params.email,
password: params.password, password: params.password,
}), }),
@@ -110,7 +118,11 @@ export async function iamLoginCode(params: {
const body = (await res.json()) as AppResponse<IamLoginCodeResponse>; const body = (await res.json()) as AppResponse<IamLoginCodeResponse>;
if (!res.ok || body.code !== 0) { if (!res.ok || body.code !== 0) {
throw new IamApiError(body.message || "Login failed", res.status, body.code); throw new IamApiError(
body.message || "Login failed",
res.status,
body.code,
);
} }
return body.data; return body.data;
} }

View File

@@ -1,7 +1,6 @@
import { clsx, type ClassValue } from "clsx" import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge" import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs));
} }

View File

@@ -1,17 +1,17 @@
import { NextRequest, NextResponse } from "next/server" import { NextRequest, NextResponse } from "next/server";
export function proxy(req: NextRequest) { export function proxy(req: NextRequest) {
if (process.env.NODE_ENV === "production") { if (process.env.NODE_ENV === "production") {
const proto = req.headers.get("x-forwarded-proto") const proto = req.headers.get("x-forwarded-proto");
if (proto && proto !== "https") { if (proto && proto !== "https") {
const url = req.nextUrl.clone() const url = req.nextUrl.clone();
url.protocol = "https" url.protocol = "https";
return NextResponse.redirect(url, 308) return NextResponse.redirect(url, 308);
} }
} }
return NextResponse.next() return NextResponse.next();
} }
export const config = { export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"], matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
} };