feat: add captcha support for user login and enhance user profile page

- Refactored userLogin function to include captcha handling.
- Introduced getCaptchaConfig API to fetch captcha configuration.
- Added Captcha component to handle different captcha providers (hCaptcha, reCaptcha, Turnstile).
- Updated LoginForm component to integrate captcha verification.
- Created UserProfile component to display user information with avatar.
- Implemented getUserByUsername API to fetch user details by username.
- Removed deprecated LoginRequest interface from user model.
- Enhanced navbar and layout with animation effects.
- Removed unused user page component and added dynamic user profile routing.
- Updated localization files to include captcha-related messages.
- Improved Gravatar component for better avatar handling.
This commit is contained in:
2025-09-10 21:15:36 +08:00
parent a7da023b1e
commit 4781d81869
28 changed files with 1048 additions and 701 deletions

View File

@ -13,150 +13,163 @@ import { useEffect, useState } from "react";
import { useStoredState } from '@/hooks/use-storage-state';
import { listLabels } from "@/api/label";
import { POST_SORT_TYPE } from "@/localstore";
import { motion } from "framer-motion";
// 定义排序类型
type SortType = 'latest' | 'popular';
export default function BlogHome() {
const [labels, setLabels] = useState<Label[]>([]);
const [posts, setPosts] = useState<Post[]>([]);
const [loading, setLoading] = useState(false);
const [sortType, setSortType, sortTypeLoaded] = useStoredState<SortType>(POST_SORT_TYPE, 'latest');
useEffect(() => {
if (!sortTypeLoaded) return;
const fetchPosts = async () => {
try {
setLoading(true);
let orderBy: string;
let desc: boolean;
switch (sortType) {
case 'latest':
orderBy = 'updated_at';
desc = true;
break;
case 'popular':
orderBy = 'heat';
desc = true;
break;
default:
orderBy = 'updated_at';
desc = true;
}
// 处理关键词,空格分割转逗号
const keywords = ""?.trim() ? ""?.trim().split(/\s+/).join(",") : undefined;
const data = await listPosts({
page: 1,
size: 10,
orderBy: orderBy,
desc: desc,
keywords
});
setPosts(data.data);
} catch (error) {
console.error("Failed to fetch posts:", error);
} finally {
setLoading(false);
}
};
fetchPosts();
}, [sortType, sortTypeLoaded]);
// 获取标签
useEffect(() => {
listLabels().then(data => {
setLabels(data.data || []);
}).catch(error => {
console.error("Failed to fetch labels:", error);
});
}, []);
// 处理排序切换
const handleSortChange = (type: SortType) => {
if (sortType !== type) {
setSortType(type);
const [labels, setLabels] = useState<Label[]>([]);
const [posts, setPosts] = useState<Post[]>([]);
const [loading, setLoading] = useState(false);
const [sortType, setSortType, sortTypeLoaded] = useStoredState<SortType>(POST_SORT_TYPE, 'latest');
useEffect(() => {
if (!sortTypeLoaded) return;
const fetchPosts = async () => {
try {
setLoading(true);
let orderBy: string;
let desc: boolean;
switch (sortType) {
case 'latest':
orderBy = 'updated_at';
desc = true;
break;
case 'popular':
orderBy = 'heat';
desc = true;
break;
default:
orderBy = 'updated_at';
desc = true;
}
// 处理关键词,空格分割转逗号
const keywords = ""?.trim() ? ""?.trim().split(/\s+/).join(",") : undefined;
const data = await listPosts({
page: 1,
size: 10,
orderBy: orderBy,
desc: desc,
keywords
});
setPosts(data.data);
} catch (error) {
console.error("Failed to fetch posts:", error);
} finally {
setLoading(false);
}
};
fetchPosts();
}, [sortType, sortTypeLoaded]);
return (
<>
{/* 主内容区域 */}
<section className="py-16">
{/* 容器 - 关键布局 */}
<div className="">
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
{/* 主要内容区域 */}
<div className="lg:col-span-3 self-start">
{/* 文章列表标题 */}
<div className="flex items-center justify-between mb-8">
<h2 className="text-3xl font-bold text-slate-900 dark:text-slate-100">
{sortType === 'latest' ? '最新文章' : '热门文章'}
{posts.length > 0 && (
<span className="text-sm font-normal text-slate-500 ml-2">
({posts.length} )
</span>
)}
</h2>
// 获取标签
useEffect(() => {
listLabels().then(data => {
setLabels(data.data || []);
}).catch(error => {
console.error("Failed to fetch labels:", error);
});
}, []);
{/* 排序按钮组 */}
<div className="flex items-center gap-2">
<Button
variant={sortType === 'latest' ? 'default' : 'outline'}
size="sm"
onClick={() => handleSortChange('latest')}
disabled={loading}
className="transition-all duration-200"
>
<Clock className="w-4 h-4 mr-2" />
</Button>
<Button
variant={sortType === 'popular' ? 'default' : 'outline'}
size="sm"
onClick={() => handleSortChange('popular')}
disabled={loading}
className="transition-all duration-200"
>
<TrendingUp className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
// 处理排序切换
const handleSortChange = (type: SortType) => {
if (sortType !== type) {
setSortType(type);
}
};
{/* 博客卡片网格 */}
<BlogCardGrid posts={posts} isLoading={loading} showPrivate={true} />
return (
<>
{/* 主内容区域 */}
<section className="py-16">
{/* 容器 - 关键布局 */}
<div className="">
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
{/* 主要内容区域 */}
<motion.div
className="lg:col-span-3 self-start"
initial={{ y: 150, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: config.animationDurationSecond, ease: "easeOut" }}>
{/* 文章列表标题 */}
<div className="flex items-center justify-between mb-8">
<h2 className="text-3xl font-bold text-slate-900 dark:text-slate-100">
{sortType === 'latest' ? '最新文章' : '热门文章'}
{posts.length > 0 && (
<span className="text-sm font-normal text-slate-500 ml-2">
({posts.length} )
</span>
)}
</h2>
{/* 加载更多按钮 */}
{!loading && posts.length > 0 && (
<div className="text-center mt-12">
<Button size="lg" className="px-8">
</Button>
</div>
)}
{/* 加载状态指示器 */}
{loading && (
<div className="text-center py-8">
<div className="inline-flex items-center gap-2 text-slate-600">
<div className="w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full animate-spin"></div>
{sortType === 'latest' ? '最新' : '热门'}...
</div>
</div>
)}
</div>
{/* 侧边栏 */}
<Sidebar
cards={[
<SidebarAbout key="about" config={config} />,
posts.length > 0 ? <SidebarHotPosts key="hot" posts={posts} sortType={sortType} /> : null,
<SidebarTags key="tags" labels={labels} />,
<SidebarMisskeyIframe key="misskey" />,
].filter(Boolean)}
/>
</div>
{/* 排序按钮 */}
<div className="flex items-center gap-2">
<Button
variant={sortType === 'latest' ? 'default' : 'outline'}
size="sm"
onClick={() => handleSortChange('latest')}
disabled={loading}
className="transition-all duration-200"
>
<Clock className="w-4 h-4 mr-2" />
</Button>
<Button
variant={sortType === 'popular' ? 'default' : 'outline'}
size="sm"
onClick={() => handleSortChange('popular')}
disabled={loading}
className="transition-all duration-200"
>
<TrendingUp className="w-4 h-4 mr-2" />
</Button>
</div>
</section>
</>
);
</div>
{/* 博客卡片网格 */}
<BlogCardGrid posts={posts} isLoading={loading} showPrivate={true} />
{/* 加载更多按钮 */}
{!loading && posts.length > 0 && (
<div className="text-center mt-12">
<Button size="lg" className="px-8">
</Button>
</div>
)}
{/* 加载状态指示器 */}
{loading && (
<div className="text-center py-8">
<div className="inline-flex items-center gap-2 text-slate-600">
<div className="w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full animate-spin"></div>
{sortType === 'latest' ? '最新' : '热门'}...
</div>
</div>
)}
</motion.div>
{/* 侧边栏 */}
<motion.div
initial={{ x: 200, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
transition={{ duration: config.animationDurationSecond, ease: "easeOut" }}
>
<Sidebar
cards={[
<SidebarAbout key="about" config={config} />,
posts.length > 0 ? <SidebarHotPosts key="hot" posts={posts} sortType={sortType} /> : null,
<SidebarTags key="tags" labels={labels} />,
<SidebarMisskeyIframe key="misskey" />,
].filter(Boolean)}
/>
</motion.div>
</div>
</div>
</section>
</>
);
}

View File

@ -3,7 +3,7 @@ import { User } from "@/models/user";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { toast } from "sonner";
import { getGravatarByUser } from "@/components/common/gravatar";
import GravatarAvatar, { getGravatarByUser } from "@/components/common/gravatar";
import { CircleUser } from "lucide-react";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox"
@ -60,7 +60,7 @@ export function CommentInput(
<div className="fade-in-up">
<div className="flex py-4 fade-in">
<div onClick={user ? () => clickToUserProfile(user.username) : clickToLogin} className="cursor-pointer flex-shrink-0 w-10 h-10 fade-in">
{user ? getGravatarByUser(user) : null}
{user && <GravatarAvatar url={user.avatarUrl} email={user.email} size={100}/>}
{!user && <CircleUser className="w-full h-full fade-in" />}
</div>
<div className="flex-1 pl-2 fade-in-up">

View File

@ -3,7 +3,7 @@ import { User } from "@/models/user";
import { useLocale, useTranslations } from "next-intl";
import { useState } from "react";
import { toast } from "sonner";
import { getGravatarByUser } from "@/components/common/gravatar";
import GravatarAvatar, { getGravatarByUser } from "@/components/common/gravatar";
import { Reply, Trash, Heart, Pencil, Lock } from "lucide-react";
import { Comment } from "@/models/comment";
import { TargetType } from "@/models/types";
@ -158,8 +158,8 @@ export function CommentItem(
return (
<div>
<div className="flex">
<div onClick={() => clickToUserProfile(comment.user.username)} className="cursor-pointer fade-in">
{getGravatarByUser(comment.user)}
<div onClick={() => clickToUserProfile(comment.user.username)} className="cursor-pointer fade-in w-12 h-12">
<GravatarAvatar email={comment.user.email} size={120}/>
</div>
<div className="flex-1 pl-2 fade-in-up">
<div className="flex gap-2 md:gap-4 items-center">

View File

@ -0,0 +1 @@
/* 选择 Turnstile 组件内的 iframe */

View File

@ -0,0 +1,61 @@
"use client"
import { useEffect } from "react";
import { GoogleReCaptcha, GoogleReCaptchaProvider } from "react-google-recaptcha-v3";
import HCaptcha from "@hcaptcha/react-hcaptcha";
import { Turnstile } from "@marsidev/react-turnstile";
import { CaptchaProvider } from "@/models/captcha";
import "./captcha.css";
import { TurnstileWidget } from "./turnstile";
export type CaptchaProps = {
provider: CaptchaProvider;
siteKey: string;
url?: string;
onSuccess: (token: string) => void;
onError: (error: string) => void;
};
export function ReCaptchaWidget(props: CaptchaProps) {
return (
<GoogleReCaptchaProvider reCaptchaKey={props.siteKey} useEnterprise={false}>
<GoogleReCaptcha action="submit" onVerify={props.onSuccess} />
</GoogleReCaptchaProvider>
);
}
export function NoCaptchaWidget(props: CaptchaProps) {
useEffect(() => {
props.onSuccess("no-captcha");
}, [props, props.onSuccess]);
return null;
}
export function HCaptchaWidget(props: CaptchaProps) {
return (
<HCaptcha
sitekey={props.siteKey}
onVerify={props.onSuccess}
onError={props.onError}
onExpire={() => props.onError?.("Captcha expired")}
/>
);
}
export default function AIOCaptchaWidget(props: CaptchaProps) {
switch (props.provider) {
case CaptchaProvider.HCAPTCHA:
return <HCaptchaWidget {...props} />;
case CaptchaProvider.RECAPTCHA:
return <ReCaptchaWidget {...props} />;
case CaptchaProvider.TURNSTILE:
return <TurnstileWidget {...props} />;
case CaptchaProvider.DISABLE:
return <NoCaptchaWidget {...props} />;
default:
throw new Error(`Unsupported captcha provider: ${props.provider}`);
}
}

View File

@ -0,0 +1,54 @@
import { useState } from "react";
import { CaptchaProps } from ".";
import { Turnstile } from "@marsidev/react-turnstile";
import { useTranslations } from "next-intl";
// 简单的转圈圈动画
function Spinner() {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: 40 }}>
<svg className="animate-spin" width="32" height="32" viewBox="0 0 50 50">
<circle className="opacity-25" cx="25" cy="25" r="20" fill="none" stroke="#e5e7eb" strokeWidth="5" />
<circle className="opacity-75" cx="25" cy="25" r="20" fill="none" stroke="#6366f1" strokeWidth="5" strokeDasharray="90 150" strokeDashoffset="0" />
</svg>
</div>
);
}
// 勾勾动画
function CheckMark() {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: 40 }}>
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#22c55e" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 10 18 4 12" />
</svg>
</div>
);
}
export function OfficialTurnstileWidget(props: CaptchaProps) {
return <div>
<Turnstile className="w-full" options={{ size: "invisible" }} siteKey={props.siteKey} onSuccess={props.onSuccess} />
</div>;
}
// 自定义包装组件
export function TurnstileWidget(props: CaptchaProps) {
const t = useTranslations("Captcha");
const [status, setStatus] = useState<'loading' | 'success'>('loading');
// 只在验证通过时才显示勾
const handleSuccess = (token: string) => {
setStatus('success');
props.onSuccess(token);
};
return (
<div className="flex items-center justify-evenly w-full border border-gray-300 rounded-md px-4 py-2 relative">
{status === 'loading' && <Spinner />}
{status === 'success' && <CheckMark />}
<div className="flex-1 text-center">{status === 'success' ? t("success") :t("doing")}</div>
<div className="absolute inset-0 opacity-0 pointer-events-none">
<OfficialTurnstileWidget {...props} onSuccess={handleSuccess} />
</div>
</div>
);
}

View File

@ -28,27 +28,25 @@ const GravatarAvatar: React.FC<GravatarAvatarProps> = ({
defaultType = "identicon"
}) => {
// 如果有自定义URL使用自定义URL
if (url) {
if (url && url.trim() !== "") {
return (
<Image
src={url}
width={size}
height={size}
className={`rounded-full object-cover ${className}`}
className={`rounded-full object-cover w-full h-full ${className}`}
alt={alt}
referrerPolicy="no-referrer"
/>
);
}
const gravatarUrl = getGravatarUrl(email, size * 10, defaultType);
return (
<Image
src={gravatarUrl}
width={size}
height={size}
className={`rounded-full object-cover ${className}`}
className={`rounded-full object-cover w-full h-full ${className}`}
alt={alt}
referrerPolicy="no-referrer"
/>
@ -63,14 +61,14 @@ interface User {
avatarUrl?: string;
}
export function getGravatarByUser(user?: User, className: string = ""): React.ReactElement {
export function getGravatarByUser({user, className="", size=640}:{user?: User, className?: string, size?: number}): React.ReactElement {
if (!user) {
return <GravatarAvatar email="" className={className} />;
}
return (
<GravatarAvatar
email={user.email || ""}
size={40}
size={size}
className={className}
alt={user.displayName || user.name || "User Avatar"}
url={user.avatarUrl}

View File

@ -4,13 +4,13 @@ import * as React from "react"
import Link from "next/link"
import {
NavigationMenu,
NavigationMenuContent,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuTrigger,
navigationMenuTriggerStyle,
NavigationMenu,
NavigationMenuContent,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuTrigger,
navigationMenuTriggerStyle,
} from "@/components/ui/navigation-menu"
import GravatarAvatar from "@/components/common/gravatar"
import { useDevice } from "@/contexts/device-context"
@ -21,157 +21,156 @@ import { Menu } from "lucide-react"
import { Switch } from "../ui/switch"
const navbarMenuComponents = [
{
title: "首页",
href: "/"
},
{
title: "文章",
children: [
{ title: "归档", href: "/archives" },
{ title: "标签", href: "/labels" },
{ title: "随机", href: "/random" }
]
},
{
title: "页面",
children: [
{ title: "关于我", href: "/about" },
{ title: "联系我", href: "/contact" },
{ title: "友链", href: "/links" },
{ title: "隐私政策", href: "/privacy-policy" },
]
}
{
title: "首页",
href: "/"
},
{
title: "文章",
children: [
{ title: "归档", href: "/archives" },
{ title: "标签", href: "/labels" },
{ title: "随机", href: "/random" }
]
},
{
title: "页面",
children: [
{ title: "关于我", href: "/about" },
{ title: "联系我", href: "/contact" },
{ title: "友链", href: "/links" },
{ title: "隐私政策", href: "/privacy-policy" },
]
}
]
export function Navbar() {
const { navbarAdditionalClassName, setMode, mode } = useDevice()
return (
<nav className={`grid grid-cols-[1fr_auto_1fr] items-center gap-4 h-16 px-4 w-full ${navbarAdditionalClassName}`}>
<div className="flex items-center justify-start">
<span className="font-bold truncate">{config.metadata.name}</span>
</div>
<div className="flex items-center justify-center">
<NavMenuCenter />
</div>
<div className="flex items-center justify-end space-x-2">
<Switch checked={mode === "dark"} onCheckedChange={(checked) => setMode(checked ? "dark" : "light")} />
<GravatarAvatar email="snowykami@outlook.com" />
<SidebarMenuClientOnly />
</div>
</nav>
)
const { navbarAdditionalClassName, setMode, mode } = useDevice()
return (
<nav className={`grid grid-cols-[1fr_auto_1fr] items-center gap-4 h-full px-4 w-full ${navbarAdditionalClassName}`}>
<div className="flex items-center justify-start">
<span className="font-bold truncate">{config.metadata.name}</span>
</div>
<div className="flex items-center justify-center">
<NavMenuCenter />
</div>
<div className="flex items-center justify-end space-x-2">
<Switch checked={mode === "dark"} onCheckedChange={(checked) => setMode(checked ? "dark" : "light")} />
<SidebarMenuClientOnly />
</div>
</nav>
)
}
function NavMenuCenter() {
return (
<NavigationMenu viewport={false} className="hidden md:block">
<NavigationMenuList className="flex space-x-1">
{navbarMenuComponents.map((item) => (
<NavigationMenuItem key={item.title}>
{item.href ? (
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
<Link href={item.href} className="flex items-center gap-1 font-extrabold">
<span>{item.title}</span>
</Link>
</NavigationMenuLink>
) : item.children ? (
<>
<NavigationMenuTrigger className="flex items-center gap-1 font-extrabold">
<span>{item.title}</span>
</NavigationMenuTrigger>
<NavigationMenuContent>
<ul className="grid gap-2 p-0 min-w-[200px] max-w-[600px] grid-cols-[repeat(auto-fit,minmax(120px,1fr))]">
{item.children.map((child) => (
<ListItem
key={child.title}
title={child.title}
href={child.href}
/>
))}
</ul>
</NavigationMenuContent>
</>
) : null}
</NavigationMenuItem>
))}
</NavigationMenuList>
</NavigationMenu>
)
return (
<NavigationMenu viewport={false} className="hidden md:block">
<NavigationMenuList className="flex space-x-1">
{navbarMenuComponents.map((item) => (
<NavigationMenuItem key={item.title}>
{item.href ? (
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
<Link href={item.href} className="flex items-center gap-1 font-extrabold">
<span>{item.title}</span>
</Link>
</NavigationMenuLink>
) : item.children ? (
<>
<NavigationMenuTrigger className="flex items-center gap-1 font-extrabold">
<span>{item.title}</span>
</NavigationMenuTrigger>
<NavigationMenuContent>
<ul className="grid gap-2 p-0 min-w-[200px] max-w-[600px] grid-cols-[repeat(auto-fit,minmax(120px,1fr))]">
{item.children.map((child) => (
<ListItem
key={child.title}
title={child.title}
href={child.href}
/>
))}
</ul>
</NavigationMenuContent>
</>
) : null}
</NavigationMenuItem>
))}
</NavigationMenuList>
</NavigationMenu>
)
}
function ListItem({
title,
children,
href,
...props
title,
children,
href,
...props
}: React.ComponentPropsWithoutRef<"li"> & { href: string }) {
return (
<li {...props} className="flex justify-center">
<NavigationMenuLink asChild>
<Link href={href} className="flex flex-col items-center text-center w-full">
<div className="text-sm leading-none font-medium">{title}</div>
<p className="text-muted-foreground line-clamp-2 text-sm leading-snug">
{children}
</p>
</Link>
</NavigationMenuLink>
</li>
)
return (
<li {...props} className="flex justify-center">
<NavigationMenuLink asChild>
<Link href={href} className="flex flex-col items-center text-center w-full">
<div className="text-sm leading-none font-medium">{title}</div>
<p className="text-muted-foreground line-clamp-2 text-sm leading-snug">
{children}
</p>
</Link>
</NavigationMenuLink>
</li>
)
}
function SidebarMenuClientOnly() {
return <SidebarMenu />;
return <SidebarMenu />;
}
function SidebarMenu() {
const [open, setOpen] = useState(false)
return (
<div className="md:hidden">
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<button
aria-label="打开菜单"
className="p-2 rounded-md hover:bg-accent transition-colors"
>
<Menu className="w-6 h-6" />
</button>
</SheetTrigger>
<SheetContent side="right" className="p-0 w-64">
{/* 可访问性要求的标题,视觉上隐藏 */}
<SheetTitle className="sr-only"></SheetTitle>
<nav className="flex flex-col gap-2 p-4">
{navbarMenuComponents.map((item) =>
item.href ? (
<Link
key={item.title}
href={item.href}
className="py-2 px-3 rounded hover:bg-accent font-bold transition-colors"
onClick={() => setOpen(false)}
>
{item.title}
</Link>
) : item.children ? (
<div key={item.title} className="mb-2">
<div className="font-bold px-3 py-2">{item.title}</div>
<div className="flex flex-col pl-4">
{item.children.map((child) => (
<Link
key={child.title}
href={child.href}
className="py-2 px-3 rounded hover:bg-accent transition-colors"
onClick={() => setOpen(false)}
>
{child.title}
</Link>
))}
</div>
</div>
) : null
)}
</nav>
</SheetContent>
</Sheet></div>
const [open, setOpen] = useState(false)
return (
<div className="md:hidden">
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<button
aria-label="打开菜单"
className="p-2 rounded-md hover:bg-accent transition-colors"
>
<Menu className="w-6 h-6" />
</button>
</SheetTrigger>
<SheetContent side="right" className="p-0 w-64">
{/* 可访问性要求的标题,视觉上隐藏 */}
<SheetTitle className="sr-only"></SheetTitle>
<nav className="flex flex-col gap-2 p-4">
{navbarMenuComponents.map((item) =>
item.href ? (
<Link
key={item.title}
href={item.href}
className="py-2 px-3 rounded hover:bg-accent font-bold transition-colors"
onClick={() => setOpen(false)}
>
{item.title}
</Link>
) : item.children ? (
<div key={item.title} className="mb-2">
<div className="font-bold px-3 py-2">{item.title}</div>
<div className="flex flex-col pl-4">
{item.children.map((child) => (
<Link
key={child.title}
href={child.href}
className="py-2 px-3 rounded hover:bg-accent transition-colors"
onClick={() => setOpen(false)}
>
{child.title}
</Link>
))}
</div>
</div>
) : null
)}
</nav>
</SheetContent>
</Sheet></div>
)
)
}

View File

@ -14,10 +14,12 @@ import { Label } from "@/components/ui/label"
import Image from "next/image"
import { useEffect, useState } from "react"
import type { OidcConfig } from "@/models/oidc-config"
import { ListOidcConfigs, userLogin } from "@/api/user"
import { getCaptchaConfig, ListOidcConfigs, userLogin } from "@/api/user"
import Link from "next/link"
import { useRouter, useSearchParams } from "next/navigation"
import { useTranslations } from "next-intl"
import Captcha from "../common/captcha"
import { CaptchaProvider } from "@/models/captcha"
export function LoginForm({
className,
@ -25,12 +27,18 @@ export function LoginForm({
}: React.ComponentProps<"div">) {
const t = useTranslations('Login')
const [oidcConfigs, setOidcConfigs] = useState<OidcConfig[]>([])
const [captchaProps, setCaptchaProps] = useState<{
provider: CaptchaProvider
siteKey: string
url?: string
} | null>(null)
const [captchaToken, setCaptchaToken] = useState<string | null>(null)
const [captchaError, setCaptchaError] = useState<string | null>(null)
const [{ username, password }, setCredentials] = useState({ username: '', password: '' })
const router = useRouter()
const searchParams = useSearchParams()
const redirectBack = searchParams.get("redirect_back") || "/"
useEffect(() => {
ListOidcConfigs()
.then((res) => {
@ -42,10 +50,20 @@ export function LoginForm({
})
}, [])
useEffect(() => {
getCaptchaConfig()
.then((res) => {
setCaptchaProps(res.data)
})
.catch((error) => {
console.error("Error fetching captcha config:", error)
})
}, [])
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault()
try {
const res = await userLogin({username, password})
const res = await userLogin({ username, password, captcha: captchaToken || "" })
console.log("Login successful:", res)
router.push(redirectBack)
} catch (error) {
@ -128,7 +146,19 @@ export function LoginForm({
onChange={e => setCredentials(c => ({ ...c, password: e.target.value }))}
/>
</div>
<Button type="submit" className="w-full" onClick={handleLogin}>
{captchaProps &&
<div className="flex justify-center items-center w-full">
<Captcha {...captchaProps} onSuccess={setCaptchaToken} onError={setCaptchaError} />
</div>
}
{captchaError && <div>
{t("captcha_error")}</div>}
<Button
type="submit"
className="w-full"
onClick={handleLogin}
disabled={!captchaToken}
>
{t("login")}
</Button>
</div>

View File

@ -0,0 +1,5 @@
import { User } from "@/models/user";
export function UserPage({user}: {user: User}) {
return <div>User: {user.username}</div>;
}

View File

@ -0,0 +1,13 @@
"use client"
import { getUserByUsername } from "@/api/user";
import { User } from "@/models/user";
import { useEffect, useState } from "react";
import { getGravatarByUser } from "../common/gravatar";
export function UserProfile({ user }: { user: User }) {
return (
<div className="flex">
{getGravatarByUser({user,className: "rounded-full mr-4"})}
</div>
);
}