perf(prettier): perf
This commit is contained in:
@@ -7,7 +7,8 @@
|
||||
"build": "next build",
|
||||
"start": "next start -p 6020",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css,scss,md}\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-checkbox": "^1.3.0",
|
||||
|
||||
@@ -64,8 +64,8 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
const res = NextResponse.json(
|
||||
{
|
||||
redirectTo: issued.redirectTo,
|
||||
expiresAt: issued.expiresAt,
|
||||
redirectTo: issued.redirect_to,
|
||||
expiresAt: issued.expires_at,
|
||||
},
|
||||
{ status: 200, headers: { "Cache-Control": "no-store" } },
|
||||
);
|
||||
|
||||
@@ -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) {
|
||||
const auth = req.headers.get("authorization") ?? ""
|
||||
const auth = req.headers.get("authorization") ?? "";
|
||||
if (auth.toLowerCase().startsWith("bearer ")) {
|
||||
const base = mustGetEnv("IAM_SERVICE_BASE_URL").replace(/\/$/, "")
|
||||
const url = `${base}/auth/logout`
|
||||
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(() => {})
|
||||
}).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
|
||||
const res = NextResponse.json(
|
||||
{ ok: true },
|
||||
{ headers: { "Cache-Control": "no-store" } },
|
||||
);
|
||||
res.cookies.delete("iam_remember_email");
|
||||
res.cookies.delete("iam_captcha");
|
||||
return res;
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
const payload = generateCaptcha(120)
|
||||
const svg = captchaSvg(payload.code)
|
||||
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,
|
||||
@@ -21,8 +21,7 @@ export async function GET() {
|
||||
sameSite: "strict",
|
||||
path: "/",
|
||||
maxAge: 120,
|
||||
})
|
||||
});
|
||||
|
||||
return res
|
||||
return res;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,5 @@ export default function ForgotPasswordPage() {
|
||||
请联系租户管理员或通过业务系统的找回流程重置密码。
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import type { Metadata } from "next"
|
||||
import "./globals.css"
|
||||
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 }) {
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="zh-CN">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import Link from "next/link"
|
||||
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>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
你可以关闭此页面,或重新登录。
|
||||
</div>
|
||||
<Link className="text-sm underline underline-offset-4" href="/login">
|
||||
返回登录页
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { redirect } from "next/navigation"
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function Home() {
|
||||
redirect("/login")
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,50 +1,52 @@
|
||||
import * as React from "react"
|
||||
import Link from "next/link"
|
||||
import * as React from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { CaptchaField } from "@/components/auth/captcha-field"
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { CaptchaField } from "@/components/auth/captcha-field";
|
||||
|
||||
export function LoginForm(props: {
|
||||
clientId: string
|
||||
tenantId: string
|
||||
callback: string
|
||||
initialEmail: string
|
||||
prefillPassword?: string
|
||||
disabled?: boolean
|
||||
externalError?: string | null
|
||||
onClearExternalError?: () => void
|
||||
captcha: string
|
||||
onCaptchaChange: (next: string) => void
|
||||
captchaKey: string
|
||||
onRefreshCaptcha: () => void
|
||||
clientId: string;
|
||||
tenantId: string;
|
||||
callback: string;
|
||||
initialEmail: string;
|
||||
prefillPassword?: string;
|
||||
disabled?: boolean;
|
||||
externalError?: string | null;
|
||||
onClearExternalError?: () => void;
|
||||
captcha: string;
|
||||
onCaptchaChange: (next: string) => void;
|
||||
captchaKey: string;
|
||||
onRefreshCaptcha: () => void;
|
||||
}) {
|
||||
const [email, setEmail] = React.useState(props.initialEmail)
|
||||
const [password, setPassword] = 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 [email, setEmail] = React.useState(props.initialEmail);
|
||||
const [password, setPassword] = React.useState("");
|
||||
const [rememberMe, setRememberMe] = React.useState(
|
||||
Boolean(props.initialEmail),
|
||||
);
|
||||
const [submitting, setSubmitting] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
setEmail(props.initialEmail)
|
||||
setRememberMe(Boolean(props.initialEmail))
|
||||
}, [props.initialEmail])
|
||||
setEmail(props.initialEmail);
|
||||
setRememberMe(Boolean(props.initialEmail));
|
||||
}, [props.initialEmail]);
|
||||
|
||||
React.useEffect(() => {
|
||||
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) {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
props.onClearExternalError?.()
|
||||
setSubmitting(true)
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
props.onClearExternalError?.();
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
@@ -58,22 +60,25 @@ export function LoginForm(props: {
|
||||
captcha: props.captcha,
|
||||
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) {
|
||||
throw new Error(json.message || "登录失败")
|
||||
throw new Error(json.message || "登录失败");
|
||||
}
|
||||
window.location.href = json.redirectTo
|
||||
window.location.href = json.redirectTo;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "登录失败")
|
||||
props.onCaptchaChange("")
|
||||
props.onRefreshCaptcha()
|
||||
setError(err instanceof Error ? err.message : "登录失败");
|
||||
props.onCaptchaChange("");
|
||||
props.onRefreshCaptcha();
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const disabled = Boolean(props.disabled) || submitting
|
||||
const disabled = Boolean(props.disabled) || submitting;
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
@@ -146,5 +151,5 @@ export function LoginForm(props: {
|
||||
{submitting ? "登录中..." : "登录"}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,54 +1,60 @@
|
||||
import * as React from "react"
|
||||
import * as React from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { CaptchaField } from "@/components/auth/captcha-field"
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { CaptchaField } from "@/components/auth/captcha-field";
|
||||
|
||||
export function RegisterForm(props: {
|
||||
tenantId: string
|
||||
disabled?: boolean
|
||||
captcha: string
|
||||
onCaptchaChange: (next: string) => void
|
||||
captchaKey: string
|
||||
onRefreshCaptcha: () => void
|
||||
onRegisterSuccessPrefillLogin: (payload: { email: string; password: string }) => void
|
||||
onLoginAfterRegister: (payload: { email: string; password: string }) => Promise<void>
|
||||
tenantId: string;
|
||||
disabled?: boolean;
|
||||
captcha: string;
|
||||
onCaptchaChange: (next: string) => void;
|
||||
captchaKey: string;
|
||||
onRefreshCaptcha: () => void;
|
||||
onRegisterSuccessPrefillLogin: (payload: {
|
||||
email: string;
|
||||
password: string;
|
||||
}) => void;
|
||||
onLoginAfterRegister: (payload: {
|
||||
email: string;
|
||||
password: string;
|
||||
}) => Promise<void>;
|
||||
}) {
|
||||
const [email, setEmail] = React.useState("")
|
||||
const [password, setPassword] = React.useState("")
|
||||
const [confirmPassword, setConfirmPassword] = React.useState("")
|
||||
const [submitting, setSubmitting] = React.useState(false)
|
||||
const [error, setError] = React.useState<string | null>(null)
|
||||
const [email, setEmail] = React.useState("");
|
||||
const [password, setPassword] = React.useState("");
|
||||
const [confirmPassword, setConfirmPassword] = React.useState("");
|
||||
const [submitting, setSubmitting] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
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) {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
const nextEmail = email.trim()
|
||||
const nextEmail = email.trim();
|
||||
if (!props.tenantId) {
|
||||
setError("缺少 tenantId 参数,无法注册")
|
||||
return
|
||||
setError("缺少 tenantId 参数,无法注册");
|
||||
return;
|
||||
}
|
||||
if (!nextEmail || !password || !confirmPassword) {
|
||||
setError("请填写邮箱与密码")
|
||||
return
|
||||
setError("请填写邮箱与密码");
|
||||
return;
|
||||
}
|
||||
if (passwordMismatch) {
|
||||
setError("两次输入的密码不一致")
|
||||
return
|
||||
setError("两次输入的密码不一致");
|
||||
return;
|
||||
}
|
||||
if (!props.captcha.trim()) {
|
||||
setError("请填写验证码")
|
||||
return
|
||||
setError("请填写验证码");
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await fetch("/api/auth/register", {
|
||||
method: "POST",
|
||||
@@ -58,31 +64,31 @@ export function RegisterForm(props: {
|
||||
email: nextEmail,
|
||||
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.status === 409) {
|
||||
throw new Error("该邮箱已注册,请直接登录")
|
||||
throw new Error("该邮箱已注册,请直接登录");
|
||||
}
|
||||
throw new Error(json.message || "注册失败")
|
||||
throw new Error(json.message || "注册失败");
|
||||
}
|
||||
if (!json.id) {
|
||||
throw new Error("注册失败")
|
||||
throw new Error("注册失败");
|
||||
}
|
||||
|
||||
props.onRegisterSuccessPrefillLogin({ email: nextEmail, password })
|
||||
await props.onLoginAfterRegister({ email: nextEmail, password })
|
||||
props.onRegisterSuccessPrefillLogin({ email: nextEmail, password });
|
||||
await props.onLoginAfterRegister({ email: nextEmail, password });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "注册失败")
|
||||
props.onCaptchaChange("")
|
||||
props.onRefreshCaptcha()
|
||||
setError(err instanceof Error ? err.message : "注册失败");
|
||||
props.onCaptchaChange("");
|
||||
props.onRefreshCaptcha();
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const disabled = Boolean(props.disabled) || submitting
|
||||
const disabled = Boolean(props.disabled) || submitting;
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
@@ -151,5 +157,5 @@ export function RegisterForm(props: {
|
||||
{submitting ? "注册中..." : "注册并登录"}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
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"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
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",
|
||||
@@ -13,7 +13,8 @@ const buttonVariants = cva(
|
||||
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",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:opacity-90",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
@@ -26,27 +27,27 @@ const buttonVariants = cva(
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
extends
|
||||
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
},
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
|
||||
@@ -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>>(
|
||||
({ 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 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 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 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 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 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 }
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { Check } from "lucide-react"
|
||||
import * as React from "react";
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||
import { Check } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
@@ -20,8 +20,7 @@ const Checkbox = React.forwardRef<
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
))
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||
|
||||
export { Checkbox }
|
||||
));
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
||||
|
||||
export { Checkbox };
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import * as React from "react";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
@@ -15,8 +15,7 @@ const Label = React.forwardRef<
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label };
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
import * as React from "react";
|
||||
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<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
@@ -17,8 +17,8 @@ const TabsList = React.forwardRef<
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
));
|
||||
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
@@ -32,8 +32,8 @@ const TabsTrigger = React.forwardRef<
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
));
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
@@ -41,10 +41,13 @@ const TabsContent = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
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}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
));
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
|
||||
@@ -1,36 +1,39 @@
|
||||
import crypto from "crypto"
|
||||
import { SignJWT } from "jose"
|
||||
import crypto from "crypto";
|
||||
import { SignJWT } from "jose";
|
||||
|
||||
import { mustGetEnv } from "@/lib/env"
|
||||
import { mustGetEnv } from "@/lib/env";
|
||||
|
||||
export type AccessTokenClaims = {
|
||||
sub?: string
|
||||
tenant_id?: string
|
||||
}
|
||||
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
|
||||
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
|
||||
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 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)
|
||||
.sign(secret);
|
||||
|
||||
return { code, jti, exp }
|
||||
return { code, jti, exp };
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
code: string
|
||||
exp: number
|
||||
nonce: string
|
||||
}
|
||||
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 }
|
||||
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 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}`
|
||||
.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
|
||||
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 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 code = userInput.trim();
|
||||
if (!/^\d{4}$/.test(code)) return false;
|
||||
|
||||
const data = `${exp}.${nonce}.${code}`
|
||||
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))
|
||||
.digest("hex");
|
||||
return crypto.timingSafeEqual(Buffer.from(mac), Buffer.from(expected));
|
||||
}
|
||||
|
||||
export function captchaSvg(code: string): string {
|
||||
const width = 120
|
||||
const height = 40
|
||||
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("")
|
||||
.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}">
|
||||
@@ -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">
|
||||
${code}
|
||||
</text>
|
||||
</svg>`
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export function mustGetEnv(name: string): string {
|
||||
const v = process.env[name]
|
||||
if (!v) throw new Error(`${name} is required`)
|
||||
return v
|
||||
const v = process.env[name];
|
||||
if (!v) throw new Error(`${name} is required`);
|
||||
return v;
|
||||
}
|
||||
|
||||
@@ -26,8 +26,8 @@ export type IamLoginResponse = {
|
||||
};
|
||||
|
||||
export type IamLoginCodeResponse = {
|
||||
redirectTo: string;
|
||||
expiresAt: number;
|
||||
redirect_to: string;
|
||||
expires_at: number;
|
||||
};
|
||||
|
||||
export type IamRegisterResponse = {
|
||||
@@ -54,7 +54,11 @@ export async function iamLogin(params: {
|
||||
|
||||
const body = (await res.json()) as AppResponse<IamLoginResponse>;
|
||||
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;
|
||||
}
|
||||
@@ -78,7 +82,11 @@ export async function iamRegister(params: {
|
||||
|
||||
const body = (await res.json()) as AppResponse<IamRegisterResponse>;
|
||||
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;
|
||||
}
|
||||
@@ -99,8 +107,8 @@ export async function iamLoginCode(params: {
|
||||
"X-Tenant-ID": params.tenantId,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
clientId: params.clientId,
|
||||
redirectUri: params.redirectUri,
|
||||
client_id: params.clientId,
|
||||
redirect_uri: params.redirectUri,
|
||||
email: params.email,
|
||||
password: params.password,
|
||||
}),
|
||||
@@ -110,7 +118,11 @@ export async function iamLoginCode(params: {
|
||||
const body = (await res.json()) as AppResponse<IamLoginCodeResponse>;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
|
||||
14
src/proxy.ts
14
src/proxy.ts
@@ -1,17 +1,17 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export function proxy(req: NextRequest) {
|
||||
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") {
|
||||
const url = req.nextUrl.clone()
|
||||
url.protocol = "https"
|
||||
return NextResponse.redirect(url, 308)
|
||||
const url = req.nextUrl.clone();
|
||||
url.protocol = "https";
|
||||
return NextResponse.redirect(url, 308);
|
||||
}
|
||||
}
|
||||
return NextResponse.next()
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user