feat: giới thiệu component AuthCard để chuẩn hóa giao diện trang xác thực và cập nhật biến thể nút.
This commit is contained in:
@@ -11,6 +11,7 @@ import { Button } from '@/features/shared/components/ui/button';
|
||||
import { Input } from '@/features/shared/components/ui/input';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { AuthControls } from '@/features/shared/components/layout/auth-controls';
|
||||
import { AuthCard } from '@/features/auth/components/auth-card';
|
||||
|
||||
/**
|
||||
* EN: Create login schema with translated messages
|
||||
@@ -105,170 +106,135 @@ export default function LoginPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
// EN: Centered login form layout with cosmic background and glassmorphism
|
||||
// VI: Layout form đăng nhập với nền vũ trụ và hiệu ứng kínhmorphism
|
||||
<main
|
||||
role="main"
|
||||
aria-label={t('auth.login.pageLabel')}
|
||||
className="min-h-screen flex items-center justify-center relative overflow-hidden bg-bg-primary py-12 px-4 sm:px-6 lg:px-8"
|
||||
>
|
||||
{/* EN: X.ai Minimal Design - No cosmic background */}
|
||||
{/* VI: X.ai Minimal Design - Không có nền vũ trụ */}
|
||||
|
||||
<>
|
||||
<AuthControls />
|
||||
|
||||
<div className="w-full max-w-md space-y-8 relative z-10 glass-appear">
|
||||
<div className="text-center space-y-4">
|
||||
<div className="flex justify-center">
|
||||
<div className="p-3 rounded-2xl bg-accent-primary/5 border border-accent-primary/10 shadow-glass-sm">
|
||||
{/* EN: X.ai Minimal - Static icon (no floating) */}
|
||||
{/* VI: X.ai Minimal - Icon tĩnh (không float) */}
|
||||
<AuthCard
|
||||
title={t('auth.login.title')}
|
||||
description={t('auth.login.description')}
|
||||
footer={
|
||||
<span>
|
||||
{t('auth.login.noAccount')}{' '}
|
||||
<Link
|
||||
href="/register"
|
||||
className="text-accent-primary hover:text-accent-primary-hover font-medium transition-colors"
|
||||
>
|
||||
{t('auth.login.signUp')}
|
||||
</Link>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<form className="space-y-6" onSubmit={handleSubmit(onSubmit)}>
|
||||
{/* EN: API Error message display / VI: Hiển thị thông báo lỗi API */}
|
||||
{apiError && (
|
||||
<div
|
||||
className="p-3 rounded-lg bg-accent-error/10 border border-accent-error/20 text-accent-error text-sm flex items-center gap-2 animate-in fade-in duration-quick"
|
||||
role="alert"
|
||||
>
|
||||
<svg
|
||||
width="40"
|
||||
height="40"
|
||||
viewBox="0 0 24 24"
|
||||
className="h-4 w-4 flex-shrink-0"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="text-text-primary"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M12 2L14.4 9.6H22L15.8 14.2L18.2 21.8L12 17.2L5.8 21.8L8.2 14.2L2 9.6H9.6L12 2Z"
|
||||
fill="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>{apiError}</span>
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold tracking-tight text-text-primary mb-2">
|
||||
{t('auth.login.title')}
|
||||
</h1>
|
||||
<p className="text-text-secondary">{t('auth.login.description')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="glass-card p-8 shadow-glass-xl border-border-primary">
|
||||
<form className="space-y-6" onSubmit={handleSubmit(onSubmit)}>
|
||||
{/* EN: API Error message display / VI: Hiển thị thông báo lỗi API */}
|
||||
{apiError && (
|
||||
<div
|
||||
className="p-3 rounded-lg bg-accent-error/10 border border-accent-error/20 text-accent-error text-sm flex items-center gap-2 animate-in fade-in duration-quick"
|
||||
role="alert"
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4 flex-shrink-0"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>{apiError}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-4">
|
||||
{/* EN: Email input field / VI: Trường nhập email */}
|
||||
<Controller
|
||||
name="email"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
type="email"
|
||||
label={t('auth.login.email')}
|
||||
placeholder="you@example.com"
|
||||
isInvalid={!!errors.email}
|
||||
errorMessage={errors.email?.message}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
autoComplete="email"
|
||||
aria-required="true"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* EN: Email input field / VI: Trường nhập email */}
|
||||
{/* EN: Password input field / VI: Trường nhập mật khẩu */}
|
||||
<div className="space-y-1">
|
||||
<Controller
|
||||
name="email"
|
||||
name="password"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
type="email"
|
||||
label={t('auth.login.email')}
|
||||
placeholder="you@example.com"
|
||||
isInvalid={!!errors.email}
|
||||
errorMessage={errors.email?.message}
|
||||
type="password"
|
||||
label={t('auth.login.password')}
|
||||
placeholder={t('auth.login.password')}
|
||||
isInvalid={!!errors.password}
|
||||
errorMessage={errors.password?.message}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
autoComplete="email"
|
||||
autoComplete="current-password"
|
||||
aria-required="true"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* EN: Password input field / VI: Trường nhập mật khẩu */}
|
||||
<div className="space-y-1">
|
||||
<Controller
|
||||
name="password"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
type="password"
|
||||
label={t('auth.login.password')}
|
||||
placeholder={t('auth.login.password')}
|
||||
isInvalid={!!errors.password}
|
||||
errorMessage={errors.password?.message}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
autoComplete="current-password"
|
||||
aria-required="true"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<label className="flex items-center gap-2 cursor-pointer group">
|
||||
<Controller
|
||||
name="rememberMe"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={field.value}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
className="w-4 h-4 rounded border-glass bg-glass-subtle text-accent-primary focus:ring-2 focus:ring-accent-primary focus:ring-offset-2 focus:ring-offset-bg-primary cursor-pointer transition-all"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<span className="text-sm text-text-secondary group-hover:text-text-primary transition-colors">
|
||||
{t('auth.login.rememberMe')}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<label className="flex items-center gap-2 cursor-pointer group">
|
||||
<Controller
|
||||
name="rememberMe"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={field.value}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
className="w-4 h-4 rounded border-glass bg-glass-subtle text-accent-primary focus:ring-2 focus:ring-accent-primary focus:ring-offset-2 focus:ring-offset-bg-primary cursor-pointer transition-all"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<span className="text-sm text-text-secondary group-hover:text-text-primary transition-colors">
|
||||
{t('auth.login.rememberMe')}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
className="text-sm font-medium text-accent-primary hover:text-accent-primary-hover transition-colors"
|
||||
>
|
||||
{t('auth.login.forgotPassword')}
|
||||
</Link>
|
||||
</div>
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
className="text-sm font-medium text-accent-primary hover:text-accent-primary-hover transition-colors"
|
||||
>
|
||||
{t('auth.login.forgotPassword')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* EN: Submit button with loading state / VI: Nút submit với trạng thái loading */}
|
||||
<Button
|
||||
type="submit"
|
||||
variant="brand"
|
||||
size="lg"
|
||||
className="w-full mt-4"
|
||||
isLoading={isLoading || isSubmitting}
|
||||
isDisabled={isLoading || isSubmitting}
|
||||
>
|
||||
{isLoading || isSubmitting
|
||||
? t('auth.login.signingIn')
|
||||
: t('auth.login.title')}
|
||||
</Button>
|
||||
|
||||
{/* EN: Sign up link / VI: Link đăng ký */}
|
||||
<p className="text-sm text-center text-text-tertiary pt-4 border-t border-glass-subtle">
|
||||
{t('auth.login.noAccount')}{' '}
|
||||
<Link
|
||||
href="/register"
|
||||
className="text-accent-primary hover:text-accent-primary-hover font-medium transition-colors"
|
||||
>
|
||||
{t('auth.login.signUp')}
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
{/* EN: Submit button with loading state / VI: Nút submit với trạng thái loading */}
|
||||
<Button
|
||||
type="submit"
|
||||
variant="brand"
|
||||
size="lg"
|
||||
className="w-full mt-4"
|
||||
isLoading={isLoading || isSubmitting}
|
||||
isDisabled={isLoading || isSubmitting}
|
||||
>
|
||||
{isLoading || isSubmitting
|
||||
? t('auth.login.signingIn')
|
||||
: t('auth.login.title')}
|
||||
</Button>
|
||||
</form>
|
||||
</AuthCard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useState, useMemo } from 'react';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { Button } from '@/features/shared/components/ui/button';
|
||||
import { Input } from '@/features/shared/components/ui/input';
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/features/shared/components/ui/card';
|
||||
import { AuthCard } from '@/features/auth/components/auth-card';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { AuthControls } from '@/features/shared/components/layout/auth-controls';
|
||||
import { cn } from '@/shared/utils';
|
||||
@@ -222,178 +222,101 @@ export default function RegisterPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
// EN: Centered register form layout with cosmic background and glassmorphism
|
||||
// VI: Layout form đăng ký với nền vũ trụ và hiệu ứng kínhmorphism
|
||||
<main
|
||||
role="main"
|
||||
aria-label={t('auth.register.createAccount')}
|
||||
className="min-h-screen flex items-center justify-center relative overflow-hidden bg-bg-primary py-12 px-4 sm:px-6 lg:px-8"
|
||||
>
|
||||
{/* EN: X.ai Minimal Design - No cosmic background */}
|
||||
{/* VI: X.ai Minimal Design - Không có nền vũ trụ */}
|
||||
|
||||
<>
|
||||
<AuthControls />
|
||||
|
||||
<div className="w-full max-w-md space-y-8 relative z-10 glass-appear">
|
||||
<div className="text-center space-y-4">
|
||||
<div className="flex justify-center">
|
||||
<div className="p-3 rounded-2xl bg-accent-primary/5 border border-accent-primary/10 shadow-glass-sm">
|
||||
{/* EN: X.ai Minimal - Static icon (no floating) */}
|
||||
{/* VI: X.ai Minimal - Icon tĩnh (không float) */}
|
||||
<AuthCard
|
||||
title={t('auth.register.createAccount')}
|
||||
description={t('auth.register.signUpToStart')}
|
||||
footer={
|
||||
<span>
|
||||
{t('auth.register.alreadyHaveAccount')}{' '}
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-accent-primary hover:text-accent-primary-hover font-medium transition-colors"
|
||||
>
|
||||
{t('auth.register.signIn')}
|
||||
</Link>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<form className="space-y-6" onSubmit={handleSubmit(onSubmit)}>
|
||||
{/* EN: API Error message display / VI: Hiển thị thông báo lỗi API */}
|
||||
{apiError && (
|
||||
<div
|
||||
className="p-3 rounded-lg bg-accent-error/10 border border-accent-error/20 text-accent-error text-sm flex items-center gap-2 animate-in fade-in duration-quick"
|
||||
role="alert"
|
||||
>
|
||||
<svg
|
||||
width="40"
|
||||
height="40"
|
||||
viewBox="0 0 24 24"
|
||||
className="h-4 w-4 flex-shrink-0"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="text-text-primary"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M12 2L14.4 9.6H22L15.8 14.2L18.2 21.8L12 17.2L5.8 21.8L8.2 14.2L2 9.6H9.6L12 2Z"
|
||||
fill="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>{apiError}</span>
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold tracking-tight text-text-primary mb-2">
|
||||
{t('auth.register.createAccount')}
|
||||
</h1>
|
||||
<p className="text-text-secondary">
|
||||
{t('auth.register.signUpToStart')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="glass-card p-8 shadow-glass-xl border-border-primary">
|
||||
<form className="space-y-6" onSubmit={handleSubmit(onSubmit)}>
|
||||
{/* EN: API Error message display / VI: Hiển thị thông báo lỗi API */}
|
||||
{apiError && (
|
||||
<div
|
||||
className="p-3 rounded-lg bg-accent-error/10 border border-accent-error/20 text-accent-error text-sm flex items-center gap-2 animate-in fade-in duration-quick"
|
||||
role="alert"
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4 flex-shrink-0"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>{apiError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* EN: Full name input field / VI: Trường nhập họ tên */}
|
||||
<Controller
|
||||
name="fullName"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
type="text"
|
||||
label={t('auth.register.fullName')}
|
||||
placeholder="John Doe"
|
||||
isInvalid={!!errors.fullName}
|
||||
errorMessage={errors.fullName?.message}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
autoComplete="name"
|
||||
aria-required="true"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* EN: Email input field / VI: Trường nhập email */}
|
||||
<Controller
|
||||
name="email"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
type="email"
|
||||
label={t('auth.register.email')}
|
||||
placeholder="you@example.com"
|
||||
isInvalid={!!errors.email}
|
||||
errorMessage={errors.email?.message}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
autoComplete="email"
|
||||
aria-required="true"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* EN: Password input field / VI: Trường nhập mật khẩu */}
|
||||
<div className="space-y-2">
|
||||
<Controller
|
||||
name="password"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
type="password"
|
||||
label={t('auth.register.password')}
|
||||
placeholder={t('auth.register.createStrongPassword')}
|
||||
isInvalid={!!errors.password}
|
||||
errorMessage={errors.password?.message}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
autoComplete="new-password"
|
||||
aria-required="true"
|
||||
/>
|
||||
)}
|
||||
<div className="space-y-4">
|
||||
{/* EN: Full name input field / VI: Trường nhập họ tên */}
|
||||
<Controller
|
||||
name="fullName"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
type="text"
|
||||
label={t('auth.register.fullName')}
|
||||
placeholder="John Doe"
|
||||
isInvalid={!!errors.fullName}
|
||||
errorMessage={errors.fullName?.message}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
autoComplete="name"
|
||||
aria-required="true"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* EN: Password strength indicator / VI: Chỉ báo độ mạnh mật khẩu */}
|
||||
{password && (
|
||||
<div className="space-y-2 px-1">
|
||||
<div className="flex gap-1.5">
|
||||
{[1, 2, 3, 4].map((step) => (
|
||||
<div
|
||||
key={step}
|
||||
className={cn(
|
||||
'h-1 flex-1 rounded-full transition-all duration-500',
|
||||
passwordStrength.percentage >= step * 25
|
||||
? getStrengthColor(passwordStrength.strength)
|
||||
: 'bg-glass-subtle'
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
'text-[10px] uppercase tracking-wider font-semibold',
|
||||
passwordStrength.strength === 'weak'
|
||||
? 'text-accent-error'
|
||||
: passwordStrength.strength === 'strong'
|
||||
? 'text-accent-success'
|
||||
: 'text-accent-warning'
|
||||
)}
|
||||
>
|
||||
{t(`auth.register.${passwordStrength.feedback}`)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* EN: Email input field / VI: Trường nhập email */}
|
||||
<Controller
|
||||
name="email"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
type="email"
|
||||
label={t('auth.register.email')}
|
||||
placeholder="you@example.com"
|
||||
isInvalid={!!errors.email}
|
||||
errorMessage={errors.email?.message}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
autoComplete="email"
|
||||
aria-required="true"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* EN: Confirm password input field / VI: Trường xác nhận mật khẩu */}
|
||||
{/* EN: Password input field / VI: Trường nhập mật khẩu */}
|
||||
<div className="space-y-2">
|
||||
<Controller
|
||||
name="confirmPassword"
|
||||
name="password"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
type="password"
|
||||
label={t('auth.register.confirmPassword')}
|
||||
placeholder={t('auth.register.reEnterPassword')}
|
||||
isInvalid={!!errors.confirmPassword}
|
||||
errorMessage={errors.confirmPassword?.message}
|
||||
label={t('auth.register.password')}
|
||||
placeholder={t('auth.register.createStrongPassword')}
|
||||
isInvalid={!!errors.password}
|
||||
errorMessage={errors.password?.message}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
@@ -403,74 +326,114 @@ export default function RegisterPage() {
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* EN: Terms and conditions checkbox / VI: Checkbox điều khoản và điều kiện */}
|
||||
<Controller
|
||||
name="terms"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<div className="space-y-2 pt-2">
|
||||
<label className="flex items-start gap-2 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={field.value}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
className="mt-1 w-4 h-4 rounded border-glass bg-glass-subtle text-accent-primary focus:ring-2 focus:ring-accent-primary focus:ring-offset-2 focus:ring-offset-bg-primary cursor-pointer flex-shrink-0 transition-all"
|
||||
aria-required="true"
|
||||
aria-invalid={errors.terms ? 'true' : 'false'}
|
||||
{/* EN: Password strength indicator / VI: Chỉ báo độ mạnh mật khẩu */}
|
||||
{password && (
|
||||
<div className="space-y-2 px-1">
|
||||
<div className="flex gap-1.5">
|
||||
{[1, 2, 3, 4].map((step) => (
|
||||
<div
|
||||
key={step}
|
||||
className={cn(
|
||||
'h-1 flex-1 rounded-full transition-all duration-500',
|
||||
passwordStrength.percentage >= step * 25
|
||||
? getStrengthColor(passwordStrength.strength)
|
||||
: 'bg-glass-subtle'
|
||||
)}
|
||||
/>
|
||||
<span className="text-sm text-text-secondary group-hover:text-text-primary transition-colors">
|
||||
{t('auth.register.agreeToTerms')}{' '}
|
||||
<Link
|
||||
href="/terms"
|
||||
className="text-accent-primary hover:text-accent-primary-hover font-medium transition-colors"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t('auth.register.termsAndConditions')}
|
||||
</Link>
|
||||
</span>
|
||||
</label>
|
||||
{errors.terms && (
|
||||
<p
|
||||
className="text-sm text-accent-error flex items-center gap-1 ml-6 animate-in fade-in duration-quick"
|
||||
role="alert"
|
||||
>
|
||||
<span>{errors.terms.message}</span>
|
||||
</p>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<p
|
||||
className={cn(
|
||||
'text-[10px] uppercase tracking-wider font-semibold',
|
||||
passwordStrength.strength === 'weak'
|
||||
? 'text-accent-error'
|
||||
: passwordStrength.strength === 'strong'
|
||||
? 'text-accent-success'
|
||||
: 'text-accent-warning'
|
||||
)}
|
||||
>
|
||||
{t(`auth.register.${passwordStrength.feedback}`)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* EN: Submit button with loading state / VI: Nút submit với trạng thái loading */}
|
||||
<Button
|
||||
type="submit"
|
||||
variant="brand"
|
||||
size="lg"
|
||||
className="w-full mt-4"
|
||||
isLoading={isLoading || isSubmitting}
|
||||
isDisabled={isLoading || isSubmitting}
|
||||
>
|
||||
{isLoading || isSubmitting
|
||||
? t('auth.register.creatingAccount')
|
||||
: t('auth.register.createAccount')}
|
||||
</Button>
|
||||
{/* EN: Confirm password input field / VI: Trường xác nhận mật khẩu */}
|
||||
<Controller
|
||||
name="confirmPassword"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
type="password"
|
||||
label={t('auth.register.confirmPassword')}
|
||||
placeholder={t('auth.register.reEnterPassword')}
|
||||
isInvalid={!!errors.confirmPassword}
|
||||
errorMessage={errors.confirmPassword?.message}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
autoComplete="new-password"
|
||||
aria-required="true"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* EN: Sign in link / VI: Link đăng nhập */}
|
||||
<p className="text-sm text-center text-text-tertiary pt-4 border-t border-glass-subtle">
|
||||
{t('auth.register.alreadyHaveAccount')}{' '}
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-accent-primary hover:text-accent-primary-hover font-medium transition-colors"
|
||||
>
|
||||
{t('auth.register.signIn')}
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
{/* EN: Terms and conditions checkbox / VI: Checkbox điều khoản và điều kiện */}
|
||||
<Controller
|
||||
name="terms"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<div className="space-y-2 pt-2">
|
||||
<label className="flex items-start gap-2 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={field.value}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
className="mt-1 w-4 h-4 rounded border-glass bg-glass-subtle text-accent-primary focus:ring-2 focus:ring-accent-primary focus:ring-offset-2 focus:ring-offset-bg-primary cursor-pointer flex-shrink-0 transition-all"
|
||||
aria-required="true"
|
||||
aria-invalid={errors.terms ? 'true' : 'false'}
|
||||
/>
|
||||
<span className="text-sm text-text-secondary group-hover:text-text-primary transition-colors">
|
||||
{t('auth.register.agreeToTerms')}{' '}
|
||||
<Link
|
||||
href="/terms"
|
||||
className="text-accent-primary hover:text-accent-primary-hover font-medium transition-colors"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t('auth.register.termsAndConditions')}
|
||||
</Link>
|
||||
</span>
|
||||
</label>
|
||||
{errors.terms && (
|
||||
<p
|
||||
className="text-sm text-accent-error flex items-center gap-1 ml-6 animate-in fade-in duration-quick"
|
||||
role="alert"
|
||||
>
|
||||
<span>{errors.terms.message}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* EN: Submit button with loading state / VI: Nút submit với trạng thái loading */}
|
||||
<Button
|
||||
type="submit"
|
||||
variant="brand"
|
||||
size="lg"
|
||||
className="w-full mt-4"
|
||||
isLoading={isLoading || isSubmitting}
|
||||
isDisabled={isLoading || isSubmitting}
|
||||
>
|
||||
{isLoading || isSubmitting
|
||||
? t('auth.register.creatingAccount')
|
||||
: t('auth.register.createAccount')}
|
||||
</Button>
|
||||
</form>
|
||||
</AuthCard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { ArrowLeft, User, Mail, Calendar, Shield, Edit } from 'lucide-react';
|
||||
import { Role } from '@goodgo/types';
|
||||
import { useUsersStore } from '../../../../stores/users-store';
|
||||
import { UserCard, UserForm } from '../../../../features/shared/components/users';
|
||||
import { Button } from '../../../../features/shared/components/ui/button';
|
||||
@@ -40,9 +41,9 @@ export default function AdminUserDetailPage() {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
// Check permissions
|
||||
const isAdmin = currentUser?.role === 'ADMIN' || currentUser?.role === 'SUPER_ADMIN';
|
||||
const canEdit = currentUser?.role === 'SUPER_ADMIN' ||
|
||||
(currentUser?.role === 'ADMIN' && user?.role !== 'SUPER_ADMIN');
|
||||
const isAdmin = currentUser?.role === Role.ADMIN || currentUser?.role === Role.SUPER_ADMIN;
|
||||
const canEdit = currentUser?.role === Role.SUPER_ADMIN ||
|
||||
(currentUser?.role === Role.ADMIN && user?.role !== Role.SUPER_ADMIN);
|
||||
|
||||
// Load user data
|
||||
useEffect(() => {
|
||||
@@ -88,7 +89,7 @@ export default function AdminUserDetailPage() {
|
||||
<Button onClick={() => fetchUser(userId)}>
|
||||
Try Again
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => router.back()}>
|
||||
<Button variant="secondary" onClick={() => router.back()}>
|
||||
Go Back
|
||||
</Button>
|
||||
</div>
|
||||
@@ -114,160 +115,158 @@ export default function AdminUserDetailPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthGuard requiredRoles={['ADMIN', 'SUPER_ADMIN']}>
|
||||
<AuthGuard requiredRoles={[Role.ADMIN, Role.SUPER_ADMIN]}>
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="max-w-4xl mx-auto p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => router.back()}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
<span>Back</span>
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">User Details</h1>
|
||||
<p className="text-gray-600">{user.email}</p>
|
||||
<div className="max-w-4xl mx-auto p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => router.back()}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
<span>Back</span>
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">User Details</h1>
|
||||
<p className="text-gray-600">{user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canEdit && !isEditing && (
|
||||
<Button
|
||||
onClick={() => setIsEditing(true)}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
<span>Edit User</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{canEdit && !isEditing && (
|
||||
<Button
|
||||
onClick={() => setIsEditing(true)}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
<span>Edit User</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<Card className="p-4 mb-6 bg-red-50 border-red-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-red-800">{error}</p>
|
||||
<Button variant="outline" size="sm" onClick={clearError}>
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
{isEditing ? (
|
||||
<UserForm
|
||||
user={user}
|
||||
loading={isLoadingUser}
|
||||
onSubmit={handleEditSubmit}
|
||||
onCancel={() => setIsEditing(false)}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* User Card */}
|
||||
<UserCard
|
||||
user={user}
|
||||
showAdminActions={false}
|
||||
className="mb-6"
|
||||
/>
|
||||
|
||||
{/* User Details Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Account Information */}
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center space-x-3 mb-4">
|
||||
<User className="w-6 h-6 text-blue-600" />
|
||||
<h3 className="text-lg font-semibold text-gray-900">Account Information</h3>
|
||||
</div>
|
||||
<dl className="space-y-3">
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">User ID</dt>
|
||||
<dd className="text-sm text-gray-900 font-mono">{user.id}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">Email</dt>
|
||||
<dd className="text-sm text-gray-900">{user.email}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">Role</dt>
|
||||
<dd className="text-sm text-gray-900">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
user.role === 'SUPER_ADMIN' ? 'bg-red-100 text-red-800' :
|
||||
user.role === 'ADMIN' ? 'bg-blue-100 text-blue-800' :
|
||||
'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{user.role}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">Status</dt>
|
||||
<dd className="text-sm text-gray-900">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
user.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{user.isActive ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</Card>
|
||||
|
||||
{/* Account Timeline */}
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center space-x-3 mb-4">
|
||||
<Calendar className="w-6 h-6 text-green-600" />
|
||||
<h3 className="text-lg font-semibold text-gray-900">Account Timeline</h3>
|
||||
</div>
|
||||
<dl className="space-y-3">
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">Created</dt>
|
||||
<dd className="text-sm text-gray-900">
|
||||
{new Date(user.createdAt).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">Last Updated</dt>
|
||||
<dd className="text-sm text-gray-900">
|
||||
{new Date(user.updatedAt).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Activity Logs (Placeholder) */}
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center space-x-3 mb-4">
|
||||
<Shield className="w-6 h-6 text-purple-600" />
|
||||
<h3 className="text-lg font-semibold text-gray-900">Recent Activity</h3>
|
||||
</div>
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-500">
|
||||
Activity logs will be displayed here in a future update.
|
||||
</p>
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<Card className="p-4 mb-6 bg-red-50 border-red-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-red-800">{error}</p>
|
||||
<Button variant="secondary" size="sm" onClick={clearError}>
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
{isEditing ? (
|
||||
<UserForm
|
||||
user={user}
|
||||
loading={isLoadingUser}
|
||||
onSubmit={handleEditSubmit}
|
||||
onCancel={() => setIsEditing(false)}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* User Card */}
|
||||
<UserCard
|
||||
user={user}
|
||||
showAdminActions={false}
|
||||
className="mb-6"
|
||||
/>
|
||||
|
||||
{/* User Details Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Account Information */}
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center space-x-3 mb-4">
|
||||
<User className="w-6 h-6 text-blue-600" />
|
||||
<h3 className="text-lg font-semibold text-gray-900">Account Information</h3>
|
||||
</div>
|
||||
<dl className="space-y-3">
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">User ID</dt>
|
||||
<dd className="text-sm text-gray-900 font-mono">{user.id}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">Email</dt>
|
||||
<dd className="text-sm text-gray-900">{user.email}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">Role</dt>
|
||||
<dd className="text-sm text-gray-900">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${user.role === 'SUPER_ADMIN' ? 'bg-red-100 text-red-800' :
|
||||
user.role === 'ADMIN' ? 'bg-blue-100 text-blue-800' :
|
||||
'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{user.role}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">Status</dt>
|
||||
<dd className="text-sm text-gray-900">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${user.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{user.isActive ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</Card>
|
||||
|
||||
{/* Account Timeline */}
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center space-x-3 mb-4">
|
||||
<Calendar className="w-6 h-6 text-green-600" />
|
||||
<h3 className="text-lg font-semibold text-gray-900">Account Timeline</h3>
|
||||
</div>
|
||||
<dl className="space-y-3">
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">Created</dt>
|
||||
<dd className="text-sm text-gray-900">
|
||||
{new Date(user.createdAt).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">Last Updated</dt>
|
||||
<dd className="text-sm text-gray-900">
|
||||
{new Date(user.updatedAt).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Activity Logs (Placeholder) */}
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center space-x-3 mb-4">
|
||||
<Shield className="w-6 h-6 text-purple-600" />
|
||||
<h3 className="text-lg font-semibold text-gray-900">Recent Activity</h3>
|
||||
</div>
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-500">
|
||||
Activity logs will be displayed here in a future update.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AuthGuard>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Plus, Search, Filter, ArrowLeft } from 'lucide-react';
|
||||
import { Role } from '@goodgo/types';
|
||||
import { useUsersStore } from '../../../stores/users-store';
|
||||
import { UsersTable, UserForm } from '../../../features/shared/components/users';
|
||||
import { Button } from '../../../features/shared/components/ui/button';
|
||||
@@ -48,19 +49,19 @@ export default function AdminUsersPage() {
|
||||
const [editingUser, setEditingUser] = useState<any>(null);
|
||||
|
||||
// Check if current user has admin permissions
|
||||
const isAdmin = currentUser?.role === 'ADMIN' || currentUser?.role === 'SUPER_ADMIN';
|
||||
const isSuperAdmin = currentUser?.role === 'SUPER_ADMIN';
|
||||
const isAdmin = currentUser?.role === Role.ADMIN || currentUser?.role === Role.SUPER_ADMIN;
|
||||
const isSuperAdmin = currentUser?.role === Role.SUPER_ADMIN;
|
||||
|
||||
// Load users on mount and when filters change
|
||||
useEffect(() => {
|
||||
if (isAdmin) {
|
||||
const params = {
|
||||
search: searchQuery || undefined,
|
||||
role: selectedRole !== 'all' ? selectedRole : undefined,
|
||||
role: selectedRole !== 'all' ? (selectedRole as Role) : undefined,
|
||||
isActive: selectedStatus !== 'all' ? selectedStatus === 'active' : undefined,
|
||||
limit: 20,
|
||||
};
|
||||
fetchUsers(params);
|
||||
fetchUsers(params as any);
|
||||
}
|
||||
}, [isAdmin, searchQuery, selectedRole, selectedStatus, fetchUsers]);
|
||||
|
||||
@@ -200,74 +201,74 @@ export default function AdminUsersPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<Card className="p-4 mb-6">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<Input
|
||||
placeholder="Search users..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
{/* Filters */}
|
||||
<Card className="p-4 mb-6">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<Input
|
||||
placeholder="Search users..."
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={selectedRole}
|
||||
onChange={(e) => setSelectedRole(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="all">All Roles</option>
|
||||
<option value="USER">User</option>
|
||||
<option value="ADMIN">Admin</option>
|
||||
{isSuperAdmin && <option value="SUPER_ADMIN">Super Admin</option>}
|
||||
</select>
|
||||
<select
|
||||
value={selectedRole}
|
||||
onChange={(e) => setSelectedRole(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="all">All Roles</option>
|
||||
<option value="USER">User</option>
|
||||
<option value="ADMIN">Admin</option>
|
||||
{isSuperAdmin && <option value="SUPER_ADMIN">Super Admin</option>}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={selectedStatus}
|
||||
onChange={(e) => setSelectedStatus(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="all">All Status</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
</select>
|
||||
<select
|
||||
value={selectedStatus}
|
||||
onChange={(e) => setSelectedStatus(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="all">All Status</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<Card className="p-4 mb-6 bg-red-50 border-red-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-red-800">{error}</p>
|
||||
<Button variant="secondary" size="sm" onClick={clearError}>
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<Card className="p-4 mb-6 bg-red-50 border-red-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-red-800">{error}</p>
|
||||
<Button variant="outline" size="sm" onClick={clearError}>
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
{/* Users Table */}
|
||||
<UsersTable
|
||||
users={users}
|
||||
loading={isLoading}
|
||||
onEditUser={setEditingUser}
|
||||
onDeleteUser={handleDeleteUser}
|
||||
onToggleUserStatus={handleToggleUserStatus}
|
||||
onBulkAction={handleBulkAction}
|
||||
showBulkActions={isSuperAdmin}
|
||||
/>
|
||||
|
||||
{/* Users Table */}
|
||||
<UsersTable
|
||||
users={users}
|
||||
loading={isLoading}
|
||||
onEditUser={setEditingUser}
|
||||
onDeleteUser={handleDeleteUser}
|
||||
onToggleUserStatus={handleToggleUserStatus}
|
||||
onBulkAction={handleBulkAction}
|
||||
showBulkActions={isSuperAdmin}
|
||||
/>
|
||||
|
||||
{/* Pagination Info */}
|
||||
{pagination && (
|
||||
<div className="mt-6 text-center text-gray-600">
|
||||
Showing {users.length} of {pagination.total} users
|
||||
(Page {pagination.page} of {pagination.totalPages})
|
||||
</div>
|
||||
)}
|
||||
{/* Pagination Info */}
|
||||
{pagination && (
|
||||
<div className="mt-6 text-center text-gray-600">
|
||||
Showing {users.length} of {pagination.total} users
|
||||
(Page {pagination.page} of {pagination.totalPages})
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create User Modal */}
|
||||
{showCreateModal && (
|
||||
@@ -300,17 +301,17 @@ export default function AdminUsersPage() {
|
||||
);
|
||||
|
||||
return (
|
||||
<AuthGuard requiredRoles={['ADMIN', 'SUPER_ADMIN']}>
|
||||
<AuthGuard requiredRoles={[Role.ADMIN, Role.SUPER_ADMIN]}>
|
||||
<ResponsiveLayout
|
||||
header={mobileHeader}
|
||||
showHeader={true}
|
||||
enablePullToRefresh={true}
|
||||
onRefresh={async () => {
|
||||
await fetchUsers();
|
||||
}}
|
||||
>
|
||||
{pageContent}
|
||||
</ResponsiveLayout>
|
||||
header={mobileHeader}
|
||||
showHeader={true}
|
||||
enablePullToRefresh={true}
|
||||
onRefresh={async () => {
|
||||
await fetchUsers();
|
||||
}}
|
||||
>
|
||||
{pageContent}
|
||||
</ResponsiveLayout>
|
||||
</AuthGuard>
|
||||
);
|
||||
}
|
||||
@@ -127,7 +127,7 @@ export default function DashboardPage() {
|
||||
{!sidebarCollapsed && (
|
||||
<div className="border-t pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleLogout}
|
||||
className="w-full flex items-center justify-center"
|
||||
@@ -237,7 +237,7 @@ export default function DashboardPage() {
|
||||
<Button
|
||||
onClick={() => router.push('/chat')}
|
||||
className="w-full flex items-center justify-start"
|
||||
variant="outline"
|
||||
variant="secondary"
|
||||
>
|
||||
<MessageCircle className="w-4 h-4 mr-3" />
|
||||
Start New Chat
|
||||
@@ -245,7 +245,7 @@ export default function DashboardPage() {
|
||||
<Button
|
||||
onClick={() => router.push('/profile')}
|
||||
className="w-full flex items-center justify-start"
|
||||
variant="outline"
|
||||
variant="secondary"
|
||||
>
|
||||
<User className="w-4 h-4 mr-3" />
|
||||
Update Profile
|
||||
@@ -253,7 +253,7 @@ export default function DashboardPage() {
|
||||
<Button
|
||||
onClick={() => router.push('/settings')}
|
||||
className="w-full flex items-center justify-start"
|
||||
variant="outline"
|
||||
variant="secondary"
|
||||
>
|
||||
<Settings className="w-4 h-4 mr-3" />
|
||||
Account Settings
|
||||
|
||||
@@ -46,10 +46,11 @@ export default function ProfilePage() {
|
||||
|
||||
const handleProfileUpdate = async (data: any) => {
|
||||
try {
|
||||
// In a real app, this would call an API to update the user profile
|
||||
// EN: In a real app, this would call an API, then update the store
|
||||
// VI: Trong app thực tế, phần này sẽ gọi API, sau đó cập nhật store
|
||||
console.log('Updating profile:', data);
|
||||
updateProfile(data);
|
||||
setIsEditing(false);
|
||||
// Mock success - in real app you'd update the auth store
|
||||
} catch (error) {
|
||||
console.error('Failed to update profile:', error);
|
||||
}
|
||||
@@ -86,16 +87,14 @@ export default function ProfilePage() {
|
||||
<h2 className="text-xl font-semibold text-gray-900">{user.email}</h2>
|
||||
<p className="text-gray-600">Member since {new Date(user.createdAt).getFullYear()}</p>
|
||||
<div className="flex items-center mt-2">
|
||||
<span className={`inline-flex items-center px-3 py-1 rounded-full text-sm font-medium ${
|
||||
user.role === 'SUPER_ADMIN' ? 'bg-red-100 text-red-800' :
|
||||
user.role === 'ADMIN' ? 'bg-blue-100 text-blue-800' :
|
||||
'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
<span className={`inline-flex items-center px-3 py-1 rounded-full text-sm font-medium ${user.role === 'SUPER_ADMIN' ? 'bg-red-100 text-red-800' :
|
||||
user.role === 'ADMIN' ? 'bg-blue-100 text-blue-800' :
|
||||
'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{user.role}
|
||||
</span>
|
||||
<span className={`ml-3 inline-flex items-center px-3 py-1 rounded-full text-sm font-medium ${
|
||||
user.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
<span className={`ml-3 inline-flex items-center px-3 py-1 rounded-full text-sm font-medium ${user.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{user.isActive ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</div>
|
||||
@@ -111,11 +110,10 @@ export default function ProfilePage() {
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id as any)}
|
||||
className={`flex items-center space-x-2 px-4 py-2 rounded-md text-sm font-medium transition-all ${
|
||||
activeTab === tab.id
|
||||
className={`flex items-center space-x-2 px-4 py-2 rounded-md text-sm font-medium transition-all ${activeTab === tab.id
|
||||
? 'bg-blue-100 text-blue-700 shadow-sm'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
<span>{tab.label}</span>
|
||||
@@ -175,9 +173,8 @@ export default function ProfilePage() {
|
||||
Account Status
|
||||
</label>
|
||||
<div className="flex items-center space-x-3 p-3 bg-gray-50 rounded-lg">
|
||||
<span className={`w-3 h-3 rounded-full ${
|
||||
user.isActive ? 'bg-green-500' : 'bg-red-500'
|
||||
}`} />
|
||||
<span className={`w-3 h-3 rounded-full ${user.isActive ? 'bg-green-500' : 'bg-red-500'
|
||||
}`} />
|
||||
<span className="text-gray-900">
|
||||
{user.isActive ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
|
||||
@@ -282,10 +282,10 @@ export default function SettingsPage() {
|
||||
<Button onClick={handleSaveSettings} className="w-full">
|
||||
Save Settings
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleExportData} className="w-full">
|
||||
<Button variant="secondary" onClick={handleExportData} className="w-full">
|
||||
Export My Data
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => window.location.href = '/profile'} className="w-full">
|
||||
<Button variant="secondary" onClick={() => window.location.href = '/profile'} className="w-full">
|
||||
Edit Profile
|
||||
</Button>
|
||||
</div>
|
||||
@@ -296,7 +296,7 @@ export default function SettingsPage() {
|
||||
<h3 className="text-lg font-semibold text-red-900 mb-4">Danger Zone</h3>
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="secondary"
|
||||
onClick={handleDeleteAccount}
|
||||
className="w-full border-red-300 text-red-700 hover:bg-red-50"
|
||||
>
|
||||
|
||||
66
apps/web-client/src/features/auth/components/auth-card.tsx
Normal file
66
apps/web-client/src/features/auth/components/auth-card.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { cn } from '@/shared/lib/utils';
|
||||
import { BrandLogo } from '@/features/shared/components/brand/brand-logo';
|
||||
|
||||
interface AuthCardProps {
|
||||
children: React.ReactNode;
|
||||
title: string;
|
||||
description?: string;
|
||||
className?: string;
|
||||
footer?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: Shared component for consistent Auth page layouts
|
||||
* VI: Component dùng chung cho layout nhất quán giữa các trang Auth
|
||||
*/
|
||||
export function AuthCard({
|
||||
children,
|
||||
title,
|
||||
description,
|
||||
className,
|
||||
footer
|
||||
}: AuthCardProps) {
|
||||
return (
|
||||
<div className="flex min-h-[calc(100vh-3.5rem)] flex-col items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="w-full max-w-md space-y-8 flex flex-col items-center">
|
||||
{/* EN: Logo & Header / VI: Logo & Tiêu đề */}
|
||||
<div className="flex flex-col items-center">
|
||||
<BrandLogo variant="icon" size="lg" className="mb-6" />
|
||||
<h1 className="text-3xl font-bold text-text-primary tracking-tight">
|
||||
{title}
|
||||
</h1>
|
||||
{description && (
|
||||
<p className="mt-2 text-sm text-text-secondary">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* EN: Main Content / VI: Nội dung chính */}
|
||||
<div className={cn(
|
||||
'w-full glass-card p-8 rounded-3xl shadow-glass-lg border border-glass-medium',
|
||||
'animate-in fade-in zoom-in-95 duration-normal',
|
||||
className
|
||||
)}>
|
||||
{children}
|
||||
|
||||
{footer && (
|
||||
<>
|
||||
<div className="relative my-8">
|
||||
<div className="absolute inset-0 flex items-center" aria-hidden="true">
|
||||
<div className="w-full border-t border-border-primary"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center text-sm text-text-secondary">
|
||||
{footer}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
import React from 'react';
|
||||
import { MessageCircle, User, Settings, Home, Search } from 'lucide-react';
|
||||
import { cn } from '@/shared/lib/utils';
|
||||
import { MobileLayout } from './mobile-layout';
|
||||
import type { BottomNavItem } from './mobile-layout';
|
||||
|
||||
/**
|
||||
* EN: Native-style Bottom Navigation Component
|
||||
@@ -30,7 +30,7 @@ export interface MobileBottomNavProps {
|
||||
/** Show badges on items */
|
||||
showBadges?: Record<string, number>;
|
||||
/** Custom navigation items */
|
||||
customItems?: MobileLayout['bottomNavItems'];
|
||||
customItems?: BottomNavItem[];
|
||||
/** Hide labels on inactive items */
|
||||
hideLabels?: boolean;
|
||||
}
|
||||
@@ -38,7 +38,7 @@ export interface MobileBottomNavProps {
|
||||
/**
|
||||
* Pre-configured bottom navigation items for common apps
|
||||
*/
|
||||
const DEFAULT_NAV_ITEMS: MobileLayout['bottomNavItems'] = [
|
||||
const DEFAULT_NAV_ITEMS: BottomNavItem[] = [
|
||||
{
|
||||
id: 'home',
|
||||
label: 'Home',
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { cn } from '@/shared/utils';
|
||||
import { cn } from '@/shared/lib/utils';
|
||||
|
||||
export interface MobileLayoutProps {
|
||||
/**
|
||||
|
||||
@@ -27,7 +27,7 @@ const buttonVariants = cva(
|
||||
// Base styles - common to all variants
|
||||
[
|
||||
'inline-flex items-center justify-center',
|
||||
'rounded-lg font-medium',
|
||||
'rounded-xl font-medium',
|
||||
'transition-all duration-quick ease-glide',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2',
|
||||
'focus-visible:ring-glass-focus focus-visible:ring-offset-bg-primary',
|
||||
@@ -39,8 +39,8 @@ const buttonVariants = cva(
|
||||
variant: {
|
||||
// Primary glass button (subtle x.ai style)
|
||||
primary: [
|
||||
'bg-glass text-text-primary',
|
||||
'border border-glass hover:border-glass-hover',
|
||||
'bg-glass-medium text-text-primary',
|
||||
'border border-glass-medium hover:border-glass-hover',
|
||||
'backdrop-blur-glass',
|
||||
'shadow-glass hover:shadow-glass-lg',
|
||||
'hover:bg-glass-hover',
|
||||
|
||||
@@ -196,7 +196,7 @@ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
className={cn(
|
||||
// Base glass input styles
|
||||
'glass-input',
|
||||
'w-full rounded-lg px-3 py-2',
|
||||
'w-full rounded-xl px-3 py-2',
|
||||
'text-base text-text-primary placeholder:text-text-tertiary',
|
||||
'transition-all duration-quick ease-smooth',
|
||||
|
||||
|
||||
@@ -2,10 +2,15 @@
|
||||
|
||||
import React from 'react';
|
||||
import { UserResponse, Role } from '@goodgo/types';
|
||||
import { Mail, Calendar, Shield, Edit, MoreHorizontal } from 'lucide-react';
|
||||
import { Mail, Calendar, Shield, Edit, MoreHorizontal, Trash2 } from 'lucide-react';
|
||||
import { Card } from '../ui/card';
|
||||
import { Button } from '../ui/button';
|
||||
import { DropdownMenu } from '../ui/dropdown-menu';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem
|
||||
} from '../ui/dropdown-menu';
|
||||
import { Switch } from '../ui/switch';
|
||||
import { cn } from '@/shared/lib/utils';
|
||||
|
||||
@@ -114,26 +119,26 @@ export function UserCard({
|
||||
|
||||
{/* Actions */}
|
||||
{showAdminActions && (
|
||||
<DropdownMenu
|
||||
trigger={
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
</Button>
|
||||
}
|
||||
items={[
|
||||
{
|
||||
label: 'Edit',
|
||||
icon: <Edit className="w-4 h-4" />,
|
||||
onClick: () => onEdit?.(user),
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
icon: <div className="w-4 h-4 bg-red-500 rounded-full" />,
|
||||
onClick: () => onDelete?.(user),
|
||||
destructive: true,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem onClick={() => onEdit?.(user)}>
|
||||
<Edit className="w-4 h-4 mr-2" />
|
||||
<span>Edit</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onDelete?.(user)}
|
||||
className="text-red-600 focus:text-red-700 focus:bg-red-50"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
<span>Delete</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
@@ -201,28 +206,29 @@ export function UserCard({
|
||||
{showAdminActions && (
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onEdit?.(user)}
|
||||
>
|
||||
<Edit className="w-4 h-4 mr-2" />
|
||||
Edit
|
||||
</Button>
|
||||
<DropdownMenu
|
||||
trigger={
|
||||
<Button variant="outline" size="sm">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="secondary" size="sm">
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
</Button>
|
||||
}
|
||||
items={[
|
||||
{
|
||||
label: 'Delete',
|
||||
icon: <div className="w-4 h-4 bg-red-500 rounded-full" />,
|
||||
onClick: () => onDelete?.(user),
|
||||
destructive: true,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem
|
||||
onClick={() => onDelete?.(user)}
|
||||
className="text-red-600 focus:text-red-700 focus:bg-red-50"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
<span>Delete</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -177,13 +177,13 @@ export function UserForm({
|
||||
id="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => handleInputChange('email', e.target.value)}
|
||||
onChange={(value) => handleInputChange('email', value)}
|
||||
className={cn(
|
||||
'pl-10',
|
||||
errors.email && 'border-red-300 focus:border-red-500 focus:ring-red-500'
|
||||
)}
|
||||
placeholder="user@example.com"
|
||||
disabled={loading}
|
||||
isDisabled={loading}
|
||||
/>
|
||||
</div>
|
||||
{errors.email && (
|
||||
@@ -202,12 +202,12 @@ export function UserForm({
|
||||
id="password"
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => handleInputChange('password', e.target.value)}
|
||||
onChange={(value) => handleInputChange('password', value)}
|
||||
className={cn(
|
||||
errors.password && 'border-red-300 focus:border-red-500 focus:ring-red-500'
|
||||
)}
|
||||
placeholder="Enter password"
|
||||
disabled={loading}
|
||||
isDisabled={loading}
|
||||
/>
|
||||
{errors.password && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.password}</p>
|
||||
@@ -222,12 +222,12 @@ export function UserForm({
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
value={formData.confirmPassword}
|
||||
onChange={(e) => handleInputChange('confirmPassword', e.target.value)}
|
||||
onChange={(value) => handleInputChange('confirmPassword', value)}
|
||||
className={cn(
|
||||
errors.confirmPassword && 'border-red-300 focus:border-red-500 focus:ring-red-500'
|
||||
)}
|
||||
placeholder="Confirm password"
|
||||
disabled={loading}
|
||||
isDisabled={loading}
|
||||
/>
|
||||
{errors.confirmPassword && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.confirmPassword}</p>
|
||||
@@ -245,7 +245,7 @@ export function UserForm({
|
||||
<Shield className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400 z-10" />
|
||||
<Select
|
||||
value={formData.role}
|
||||
onValueChange={(value) => handleInputChange('role', value as Role)}
|
||||
onChange={(e) => handleInputChange('role', e.target.value as Role)}
|
||||
disabled={loading}
|
||||
>
|
||||
{roleOptions.map((option) => (
|
||||
@@ -279,9 +279,9 @@ export function UserForm({
|
||||
{onCancel && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
variant="secondary"
|
||||
onClick={onCancel}
|
||||
disabled={loading}
|
||||
isDisabled={loading}
|
||||
>
|
||||
<X className="w-4 h-4 mr-2" />
|
||||
Cancel
|
||||
@@ -289,7 +289,7 @@ export function UserForm({
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
isDisabled={loading}
|
||||
className="min-w-[120px]"
|
||||
>
|
||||
{loading ? (
|
||||
|
||||
@@ -5,7 +5,12 @@ import { UserResponse, Role } from '@goodgo/types';
|
||||
import { MoreHorizontal, Edit, Trash2, UserCheck, UserX } from 'lucide-react';
|
||||
import { Button } from '../ui/button';
|
||||
import { Card } from '../ui/card';
|
||||
import { DropdownMenu } from '../ui/dropdown-menu';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem
|
||||
} from '../ui/dropdown-menu';
|
||||
import { Switch } from '../ui/switch';
|
||||
import { cn } from '@/shared/lib/utils';
|
||||
|
||||
@@ -63,7 +68,7 @@ export function UsersTable({
|
||||
let aValue: any = a[sortField];
|
||||
let bValue: any = b[sortField];
|
||||
|
||||
if (sortField === 'createdAt' || sortField === 'updatedAt') {
|
||||
if (sortField === 'createdAt') {
|
||||
aValue = new Date(aValue).getTime();
|
||||
bValue = new Date(bValue).getTime();
|
||||
}
|
||||
@@ -143,7 +148,7 @@ export function UsersTable({
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => handleBulkAction('activate')}
|
||||
className="text-green-700 border-green-300 hover:bg-green-50"
|
||||
@@ -152,7 +157,7 @@ export function UsersTable({
|
||||
Activate
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => handleBulkAction('deactivate')}
|
||||
className="text-orange-700 border-orange-300 hover:bg-orange-50"
|
||||
@@ -161,7 +166,7 @@ export function UsersTable({
|
||||
Deactivate
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => handleBulkAction('delete')}
|
||||
className="text-red-700 border-red-300 hover:bg-red-50"
|
||||
@@ -262,26 +267,26 @@ export function UsersTable({
|
||||
{new Date(user.createdAt).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<DropdownMenu
|
||||
trigger={
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
</Button>
|
||||
}
|
||||
items={[
|
||||
{
|
||||
label: 'Edit',
|
||||
icon: <Edit className="w-4 h-4" />,
|
||||
onClick: () => onEditUser?.(user),
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
icon: <Trash2 className="w-4 h-4" />,
|
||||
onClick: () => onDeleteUser?.(user),
|
||||
destructive: true,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem onClick={() => onEditUser?.(user)}>
|
||||
<Edit className="w-4 h-4 mr-2" />
|
||||
<span>Edit</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onDeleteUser?.(user)}
|
||||
className="text-red-600 focus:text-red-700 focus:bg-red-50"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
<span>Delete</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
// Các hooks sẽ được thêm vào khi được implement
|
||||
|
||||
export * from './use-device-type';
|
||||
export * from './use-translation';
|
||||
// export * from './use-translation';
|
||||
export * from './use-keyboard-shortcuts';
|
||||
// export * from './use-media-query';
|
||||
// export * from './use-breakpoint';
|
||||
|
||||
@@ -25,6 +25,8 @@ interface AuthState {
|
||||
fetchUser: () => Promise<void>;
|
||||
/** EN: OAuth login method / VI: Method đăng nhập OAuth */
|
||||
oauthLogin: (accessToken: string) => Promise<void>;
|
||||
/** EN: Update profile method / VI: Method cập nhật profile */
|
||||
updateProfile: (data: Partial<UserResponse>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -188,6 +190,18 @@ export const useAuthStore = create<AuthState>()(
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* EN: Update user profile in store
|
||||
* VI: Cập nhật hồ sơ người dùng trong store
|
||||
*
|
||||
* @param data - Updated user data / Dữ liệu người dùng đã cập nhật
|
||||
*/
|
||||
updateProfile: (data: Partial<UserResponse>) => {
|
||||
set((state) => ({
|
||||
user: state.user ? { ...state.user, ...data } : null,
|
||||
}));
|
||||
},
|
||||
}),
|
||||
{
|
||||
// EN: Persist auth state to localStorage
|
||||
|
||||
Reference in New Issue
Block a user