Added User List table

This commit is contained in:
2026-01-20 22:21:06 +07:00
parent 1423d8af53
commit e02564b5cd
45 changed files with 1866 additions and 292 deletions

View File

@@ -1,24 +1,14 @@
import { m } from '@/paraglide/messages';
import { formatters } from '@/utils/formatters';
import { jsonSupport } from '@/utils/help';
import { EyeIcon } from '@phosphor-icons/react';
import { ColumnDef } from '@tanstack/react-table';
import { Badge } from '../ui/badge';
import { Button } from '../ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '../ui/dialog';
import { Label } from '../ui/label';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import ActionBadge, { UserActionType } from './action-badge';
export const logColumns: ColumnDef<AuditLog>[] = [
import ActionBadge, { UserActionType } from './action-badge';
import ViewDetail from './view-detail-dialog';
export const logColumns: ColumnDef<AuditWithUser>[] = [
{
accessorKey: 'user.name',
accessorFn: (row) => row.user?.name ?? '',
header: m.logs_page_ui_table_header_username(),
meta: {
thClass: 'w-1/6',
@@ -72,88 +62,3 @@ export const logColumns: ColumnDef<AuditLog>[] = [
),
},
];
type ViewDetailProps = {
data: AuditLog;
};
const ViewDetail = ({ data }: ViewDetailProps) => {
return (
<Dialog>
<Tooltip>
<TooltipTrigger asChild>
<DialogTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="rounded-full cursor-pointer text-blue-500 hover:bg-blue-100 hover:text-blue-600"
>
<EyeIcon size={16} />
<span className="sr-only">View</span>
</Button>
</DialogTrigger>
</TooltipTrigger>
<TooltipContent
side="left"
className="bg-blue-300 [&_svg]:bg-blue-300 [&_svg]:fill-blue-300 text-white"
>
<Label>View</Label>
</TooltipContent>
</Tooltip>
<DialogContent className="max-w-100 xl:max-w-2xl">
<DialogHeader>
<DialogTitle>
{m.ui_dialog_view_title({ type: m.nav_log() })}
</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<span className="font-bold">
{m.logs_page_ui_table_header_username()}:
</span>
<Label>{data.user.name}</Label>
</div>
<div className="flex items-center gap-2">
<span className="font-bold">
{m.logs_page_ui_table_header_table()}:
</span>
<Badge variant="table" className="px-3 py-1 text-xs">
{data.tableName}
</Badge>
</div>
<div className="flex items-center gap-2">
<span className="font-bold">
{m.logs_page_ui_table_header_action()}:
</span>
<ActionBadge action={data.action as keyof UserActionType} />
</div>
{data.oldValue && (
<div className="flex flex-col gap-2">
<span className="font-bold">
{m.logs_page_ui_table_header_old_value()}:
</span>
<pre className="whitespace-pre-wrap wrap-break-word">
{jsonSupport(data.oldValue)}
</pre>
</div>
)}
<div className="flex flex-col gap-2">
<span className="font-bold">
{m.logs_page_ui_table_header_new_value()}:
</span>
<pre className="whitespace-pre-wrap wrap-break-word">
{data.newValue ? jsonSupport(data.newValue) : ''}
</pre>
</div>
<div className="flex items-center gap-2">
<span className="font-bold">
{m.logs_page_ui_table_header_create_at()}:
</span>
<span>{formatters.dateTime(new Date(data.createdAt))}</span>
</div>
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,115 @@
import usePreventAutoFocus from '@/hooks/use-prevent-auto-focus';
import { m } from '@/paraglide/messages';
import { formatters } from '@/utils/formatters';
import { jsonSupport } from '@/utils/helper';
import { EyeIcon } from '@phosphor-icons/react';
import { Badge } from '../ui/badge';
import { Button } from '../ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '../ui/dialog';
import { Label } from '../ui/label';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import ActionBadge, { UserActionType } from './action-badge';
type ViewDetailProps = {
data: AuditWithUser;
};
const ViewDetail = ({ data }: ViewDetailProps) => {
const prevent = usePreventAutoFocus();
return (
<Dialog>
<Tooltip>
<TooltipTrigger asChild>
<DialogTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="rounded-full cursor-pointer text-blue-500 hover:bg-blue-100 hover:text-blue-600"
>
<EyeIcon size={16} />
<span className="sr-only">{m.ui_view_btn()}</span>
</Button>
</DialogTrigger>
</TooltipTrigger>
<TooltipContent
side="left"
className="bg-blue-500 [&_svg]:bg-blue-500 [&_svg]:fill-blue-500 text-white"
>
<Label>{m.ui_view_btn()}</Label>
</TooltipContent>
</Tooltip>
<DialogContent
className="max-w-100 xl:max-w-2xl"
{...prevent}
onPointerDownOutside={(e) => e.preventDefault()}
>
<DialogHeader>
<DialogTitle className="flex items-center gap-3 text-lg font-bold text-blue-600">
<EyeIcon size={20} />
{m.ui_dialog_view_title({ type: m.nav_logs() })}
</DialogTitle>
<DialogDescription className="sr-only">
{m.ui_dialog_view_title({ type: m.nav_logs() })}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<span className="font-bold">
{m.logs_page_ui_table_header_username()}:
</span>
<Label>{data.user?.name}</Label>
</div>
<div className="flex items-center gap-2">
<span className="font-bold">
{m.logs_page_ui_table_header_table()}:
</span>
<Badge variant="table" className="px-3 py-1 text-xs">
{data.tableName}
</Badge>
</div>
<div className="flex items-center gap-2">
<span className="font-bold">
{m.logs_page_ui_table_header_action()}:
</span>
<ActionBadge action={data.action as keyof UserActionType} />
</div>
{data.oldValue && (
<div className="flex flex-col gap-2">
<span className="font-bold">
{m.logs_page_ui_table_header_old_value()}:
</span>
<pre className="whitespace-pre-wrap wrap-break-word">
{jsonSupport(data.oldValue)}
</pre>
</div>
)}
<div className="flex flex-col gap-2">
<span className="font-bold">
{m.logs_page_ui_table_header_new_value()}:
</span>
<pre className="whitespace-pre-wrap wrap-break-word">
{data.newValue ? jsonSupport(data.newValue) : ''}
</pre>
</div>
<div className="flex items-center gap-2">
<span className="font-bold">
{m.logs_page_ui_table_header_create_at()}:
</span>
<span>{formatters.dateTime(new Date(data.createdAt))}</span>
</div>
</div>
</DialogContent>
</Dialog>
);
};
export default ViewDetail;

View File

@@ -0,0 +1,193 @@
import { m } from '@/paraglide/messages';
import { usersQueries } from '@/service/queries';
import { banUser } from '@/service/user.api';
import { userBanSchema } from '@/service/user.schema';
import { ReturnError } from '@/types/common';
import { WarningIcon } from '@phosphor-icons/react';
import { useForm } from '@tanstack/react-form';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { UserWithRole } from 'better-auth/plugins';
import { toast } from 'sonner';
import { Alert, AlertDescription, AlertTitle } from '../ui/alert';
import { Button } from '../ui/button';
import { DialogClose, DialogFooter } from '../ui/dialog';
import { Field, FieldError, FieldGroup, FieldLabel } from '../ui/field';
import { Input } from '../ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '../ui/select';
import { Textarea } from '../ui/textarea';
type FormProps = {
data: UserWithRole;
onSubmit: (open: boolean) => void;
};
const BanUserForm = ({ data, onSubmit }: FormProps) => {
const queryClient = useQueryClient();
const banUserMutation = useMutation({
mutationFn: banUser,
onSuccess: () => {
queryClient.refetchQueries({
queryKey: usersQueries.all,
});
onSubmit(false);
toast.success(m.users_page_message_banned_success({ name: data.name }), {
richColors: true,
});
},
onError: (error: ReturnError) => {
console.error(error);
toast.error(
(m[`backend_${error.code}` as keyof typeof m] as () => string)(),
{ richColors: true },
);
},
});
const form = useForm({
defaultValues: {
id: data.id,
banReason: '',
banExp: 0,
},
validators: {
onChange: userBanSchema,
onSubmit: userBanSchema,
},
onSubmit: async ({ value }) => {
banUserMutation.mutate({ data: value });
},
});
return (
<form
id="admin-ban-user-form"
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
}}
>
<FieldGroup>
<Alert variant="destructive">
<WarningIcon />
<AlertTitle>
{m.profile_form_name()}: {data.name}
</AlertTitle>
<AlertDescription className="sr-only">adá</AlertDescription>
</Alert>
<form.Field
name="id"
children={(field) => {
const isInvalid =
field.state.meta.isTouched && !field.state.meta.isValid;
return (
<Field data-invalid={isInvalid}>
<Input
type="hidden"
name={field.name}
id={field.name}
value={field.state.value}
aria-invalid={isInvalid}
/>
{isInvalid && <FieldError errors={field.state.meta.errors} />}
</Field>
);
}}
/>
<form.Field
name="banReason"
children={(field) => {
const isInvalid =
field.state.meta.isTouched && !field.state.meta.isValid;
return (
<Field data-invalid={isInvalid} className="col-span-2">
<FieldLabel htmlFor={field.name}>
{m.users_page_ui_form_ban_reason()}:
</FieldLabel>
<Textarea
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
aria-invalid={isInvalid}
rows={4}
/>
{isInvalid && <FieldError errors={field.state.meta.errors} />}
</Field>
);
}}
/>
<form.Field
name="banExp"
children={(field) => {
const isInvalid =
field.state.meta.isTouched && !field.state.meta.isValid;
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={field.name}>
{m.users_page_ui_form_ban_exp()}
</FieldLabel>
<Select
name={field.name}
value={String(field.state.value)}
onValueChange={(value) => field.handleChange(Number(value))}
>
<SelectTrigger aria-invalid={isInvalid}>
<SelectValue
placeholder={m.users_page_ui_select_placeholder_ban_exp()}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="1">
{m.exp_time({ time: '1d' })}
</SelectItem>
<SelectItem value="7">
{m.exp_time({ time: '7d' })}
</SelectItem>
<SelectItem value="15">
{m.exp_time({ time: '15d' })}
</SelectItem>
<SelectItem value="30">
{m.exp_time({ time: '1m' })}
</SelectItem>
<SelectItem value="180">
{m.exp_time({ time: '6m' })}
</SelectItem>
<SelectItem value="365">
{m.exp_time({ time: '1y' })}
</SelectItem>
<SelectItem value="99999">
{m.exp_time({ time: '0' })}
</SelectItem>
</SelectContent>
</Select>
</Field>
);
}}
/>
<Field>
<DialogFooter>
<DialogClose asChild>
<Button variant="destructive" type="button">
{m.ui_cancel_btn()}
</Button>
</DialogClose>
<Button type="submit" variant="outline">
{m.ui_ban_btn()}
</Button>
</DialogFooter>
</Field>
</FieldGroup>
</form>
);
};
export default BanUserForm;

View File

@@ -0,0 +1,124 @@
import { m } from '@/paraglide/messages';
import { usersQueries } from '@/service/queries';
import { setUserPassword } from '@/service/user.api';
import { userSetPasswordSchema } from '@/service/user.schema';
import { ReturnError } from '@/types/common';
import { useForm } from '@tanstack/react-form';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { UserWithRole } from 'better-auth/plugins';
import { toast } from 'sonner';
import { Button } from '../ui/button';
import { DialogClose, DialogFooter } from '../ui/dialog';
import { Field, FieldError, FieldGroup, FieldLabel } from '../ui/field';
import { Input } from '../ui/input';
type FormProps = {
data: UserWithRole;
onSubmit: (open: boolean) => void;
};
const AdminSetPasswordForm = ({ data, onSubmit }: FormProps) => {
const queryClient = useQueryClient();
const setUserPasswordMutation = useMutation({
mutationFn: setUserPassword,
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: [...usersQueries.all, 'list'],
});
onSubmit(false);
toast.success(m.users_page_message_set_password_success(), {
richColors: true,
});
},
onError: (error: ReturnError) => {
console.error(error);
toast.error(
(m[`backend_${error.code}` as keyof typeof m] as () => string)(),
{ richColors: true },
);
},
});
const form = useForm({
defaultValues: {
id: data.id,
password: '',
},
validators: {
onSubmit: userSetPasswordSchema,
},
onSubmit: async ({ value }) => {
setUserPasswordMutation.mutate({ data: value });
},
});
return (
<form
id="admin-set-password-form"
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
}}
>
<FieldGroup>
<form.Field
name="id"
children={(field) => {
const isInvalid =
field.state.meta.isTouched && !field.state.meta.isValid;
return (
<Field data-invalid={isInvalid}>
<Input
type="hidden"
name={field.name}
id={field.name}
value={field.state.value}
aria-invalid={isInvalid}
/>
{isInvalid && <FieldError errors={field.state.meta.errors} />}
</Field>
);
}}
/>
<form.Field
name="password"
children={(field) => {
const isInvalid =
field.state.meta.isTouched && !field.state.meta.isValid;
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={field.name}>
{m.change_password_form_new_password()}
</FieldLabel>
<Input
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
aria-invalid={isInvalid}
type="password"
/>
{isInvalid && <FieldError errors={field.state.meta.errors} />}
</Field>
);
}}
/>
<Field>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline" type="button">
{m.ui_cancel_btn()}
</Button>
</DialogClose>
<Button type="submit">{m.ui_save_btn()}</Button>
</DialogFooter>
</Field>
</FieldGroup>
</form>
);
};
export default AdminSetPasswordForm;

View File

@@ -0,0 +1,146 @@
import { m } from '@/paraglide/messages';
import { usersQueries } from '@/service/queries';
import { setUserRole } from '@/service/user.api';
import { RoleEnum, userUpdateRoleSchema } from '@/service/user.schema';
import { ReturnError } from '@/types/common';
import { useForm } from '@tanstack/react-form';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { UserWithRole } from 'better-auth/plugins';
import { toast } from 'sonner';
import { Button } from '../ui/button';
import { DialogClose, DialogFooter } from '../ui/dialog';
import { Field, FieldError, FieldGroup, FieldLabel } from '../ui/field';
import { Input } from '../ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '../ui/select';
type SetRoleFormProps = {
data: UserWithRole;
onSubmit: (open: boolean) => void;
};
const AdminSetUserRoleForm = ({ data, onSubmit }: SetRoleFormProps) => {
const queryClient = useQueryClient();
const defaultFormValues = {
id: data.id,
role: data.role,
};
const updateRoleMutation = useMutation({
mutationFn: setUserRole,
onSuccess: () => {
queryClient.refetchQueries({
queryKey: usersQueries.all,
});
onSubmit(false);
toast.success(m.users_page_message_set_role_success(), {
richColors: true,
});
},
onError: (error: ReturnError) => {
console.error(error);
toast.error(
(m[`backend_${error.code}` as keyof typeof m] as () => string)(),
{ richColors: true },
);
},
});
const form = useForm({
defaultValues: userUpdateRoleSchema.parse(defaultFormValues),
validators: {
onChange: userUpdateRoleSchema,
onSubmit: userUpdateRoleSchema,
},
onSubmit: async ({ value }) => {
updateRoleMutation.mutate({ data: value });
},
});
return (
<form
id="admin-set-user-role-form"
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
}}
>
<FieldGroup>
<form.Field
name="id"
children={(field) => {
const isInvalid =
field.state.meta.isTouched && !field.state.meta.isValid;
return (
<Field data-invalid={isInvalid}>
<Input
type="hidden"
name={field.name}
id={field.name}
value={field.state.value}
aria-invalid={isInvalid}
/>
{isInvalid && <FieldError errors={field.state.meta.errors} />}
</Field>
);
}}
/>
<form.Field
name="role"
children={(field) => {
const isInvalid =
field.state.meta.isTouched && !field.state.meta.isValid;
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={field.name}>
{m.profile_form_role()}
</FieldLabel>
<Select
name={field.name}
value={field.state.value}
onValueChange={(value) =>
field.handleChange(RoleEnum.parse(value))
}
>
<SelectTrigger aria-invalid={isInvalid}>
<SelectValue
placeholder={m.users_page_ui_select_placeholder_language()}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="admin">
{m.role_tags({ role: 'admin' })}
</SelectItem>
<SelectItem value="user">
{m.role_tags({ role: 'user' })}
</SelectItem>
</SelectContent>
</Select>
{isInvalid && <FieldError errors={field.state.meta.errors} />}
</Field>
);
}}
/>
<Field>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline" type="button">
{m.ui_cancel_btn()}
</Button>
</DialogClose>
<Button type="submit">{m.ui_save_btn()}</Button>
</DialogFooter>
</Field>
</FieldGroup>
</form>
);
};
export default AdminSetUserRoleForm;

View File

@@ -0,0 +1,123 @@
import { m } from '@/paraglide/messages';
import { usersQueries } from '@/service/queries';
import { updateUserInformation } from '@/service/user.api';
import { userUpdateInfoSchema } from '@/service/user.schema';
import { ReturnError } from '@/types/common';
import { useForm } from '@tanstack/react-form';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { UserWithRole } from 'better-auth/plugins';
import { toast } from 'sonner';
import { Button } from '../ui/button';
import { DialogClose, DialogFooter } from '../ui/dialog';
import { Field, FieldError, FieldGroup, FieldLabel } from '../ui/field';
import { Input } from '../ui/input';
type UpdateUserFormProps = {
data: UserWithRole;
onSubmit: (open: boolean) => void;
};
const AdminUpdateUserInfoForm = ({ data, onSubmit }: UpdateUserFormProps) => {
const queryClient = useQueryClient();
const updateUserMutation = useMutation({
mutationFn: updateUserInformation,
onSuccess: () => {
queryClient.refetchQueries({
queryKey: usersQueries.all,
});
onSubmit(false);
toast.success(m.users_page_message_update_info_success(), {
richColors: true,
});
},
onError: (error: ReturnError) => {
console.error(error);
toast.error(
(m[`backend_${error.code}` as keyof typeof m] as () => string)(),
{ richColors: true },
);
},
});
const form = useForm({
defaultValues: {
id: data.id,
name: data.name,
},
validators: {
onChange: userUpdateInfoSchema,
},
onSubmit: async ({ value }) => {
updateUserMutation.mutate({ data: value });
},
});
return (
<form
id="admin-update-user-info-form"
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
}}
>
<FieldGroup>
<form.Field
name="id"
children={(field) => {
const isInvalid =
field.state.meta.isTouched && !field.state.meta.isValid;
return (
<Field data-invalid={isInvalid}>
<Input
type="hidden"
name={field.name}
id={field.name}
value={field.state.value}
aria-invalid={isInvalid}
/>
{isInvalid && <FieldError errors={field.state.meta.errors} />}
</Field>
);
}}
/>
<form.Field
name="name"
children={(field) => {
const isInvalid =
field.state.meta.isTouched && !field.state.meta.isValid;
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={field.name}>
{m.profile_form_name()}
</FieldLabel>
<Input
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
aria-invalid={isInvalid}
type="text"
/>
{isInvalid && <FieldError errors={field.state.meta.errors} />}
</Field>
);
}}
/>
<Field>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline" type="button">
{m.ui_cancel_btn()}
</Button>
</DialogClose>
<Button type="submit">{m.ui_save_btn()}</Button>
</DialogFooter>
</Field>
</FieldGroup>
</form>
);
};
export default AdminUpdateUserInfoForm;

View File

@@ -70,7 +70,7 @@ const ChangePasswordForm = () => {
);
},
onError: (ctx) => {
console.log(ctx.error.code);
console.error(ctx.error.code);
toast.error(
(
m[`backend_${ctx.error.code}` as keyof typeof m] as () => string

View File

@@ -66,6 +66,7 @@ const ProfileForm = () => {
});
},
onError: (ctx) => {
console.error(ctx.error.code);
toast.error(
(
m[
@@ -79,7 +80,9 @@ const ProfileForm = () => {
},
},
);
} catch (error) {}
} catch (error) {
console.error('update load file', error);
}
},
});

View File

@@ -2,6 +2,7 @@ import { m } from '@/paraglide/messages';
import { settingQueries } from '@/service/queries';
import { updateAdminSettings } from '@/service/setting.api';
import { settingSchema, SettingsInput } from '@/service/setting.schema';
import { ReturnError } from '@/types/common';
import { GearIcon } from '@phosphor-icons/react';
import { useForm } from '@tanstack/react-form';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
@@ -32,6 +33,13 @@ const SettingsForm = () => {
richColors: true,
});
},
onError: (error: ReturnError) => {
console.error(error);
toast.error(
(m[`backend_${error.code}` as keyof typeof m] as () => string)(),
{ richColors: true },
);
},
});
const form = useForm({

View File

@@ -51,6 +51,7 @@ const SignInForm = () => {
});
},
onError: (ctx) => {
console.error(ctx.error.code);
toast.error(
(
m[`backend_${ctx.error.code}` as keyof typeof m] as () => string

View File

@@ -3,6 +3,7 @@ import { Locale, setLocale } from '@/paraglide/runtime';
import { settingQueries } from '@/service/queries';
import { updateUserSettings } from '@/service/setting.api';
import { UserSettingInput, userSettingSchema } from '@/service/setting.schema';
import { ReturnError } from '@/types/common';
import { GearIcon } from '@phosphor-icons/react';
import { useForm } from '@tanstack/react-form';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
@@ -40,6 +41,13 @@ const UserSettingsForm = () => {
richColors: true,
});
},
onError: (error: ReturnError) => {
console.error(error);
toast.error(
(m[`backend_${error.code}` as keyof typeof m] as () => string)(),
{ richColors: true },
);
},
});
const form = useForm({

View File

@@ -4,6 +4,7 @@ import {
SidebarFooter,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from '@/components/ui/sidebar';
import { Link } from '@tanstack/react-router';
@@ -16,12 +17,14 @@ const AppSidebar = ({ ...props }: React.ComponentProps<typeof Sidebar>) => {
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<h1 className="text-xl font-semibold">
<Link to="/" className="flex items-center gap-2" title="Fuware">
<img src="/logo.svg" alt="Fuware Logo" className="h-8" />
Fuware
</Link>
</h1>
<SidebarMenuButton size="lg" asChild>
<h1 className="text-xl! font-semibold">
<Link to="/" className="flex items-center gap-2" title="Fuware">
<img src="/logo.svg" alt="Fuware Logo" className="h-8" />
Fuware
</Link>
</h1>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>

View File

@@ -4,6 +4,7 @@ import {
GaugeIcon,
GearIcon,
HouseIcon,
UsersIcon,
} from '@phosphor-icons/react';
import { createLink } from '@tanstack/react-router';
import AdminShow from '../auth/AdminShow';
@@ -45,15 +46,22 @@ const NAV_MAIN = [
title: 'Management',
items: [
{
title: m.nav_log(),
path: '/logs',
title: m.nav_users(),
path: '/kanri/users',
icon: UsersIcon,
isAuth: false,
admin: true,
},
{
title: m.nav_logs(),
path: '/kanri/logs',
icon: CircuitryIcon,
isAuth: false,
admin: true,
},
{
title: m.nav_settings(),
path: '/settings',
path: '/kanri/settings',
icon: GearIcon,
isAuth: false,
admin: true,
@@ -66,7 +74,7 @@ const NavMain = () => {
return (
<>
{NAV_MAIN.map((nav) => (
<SidebarGroup key={nav.id}>
<SidebarGroup key={nav.id} className="overflow-hidden">
<SidebarGroupLabel>{nav.title}</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
@@ -75,6 +83,7 @@ const NavMain = () => {
const Menu = (
<SidebarMenuItem>
<SidebarMenuButtonLink
type="button"
to={item.path}
className="cursor-pointer"
tooltip={item.title}

View File

@@ -43,11 +43,12 @@ const NavUser = () => {
onSuccess: () => {
navigate({ to: '/' });
queryClient.invalidateQueries({ queryKey: ['auth', 'session'] });
toast.success(m.login_page_messages_login_success(), {
toast.success(m.login_page_messages_logout_success(), {
richColors: true,
});
},
onError: (ctx) => {
console.error(ctx.error.code);
toast.error(
(
m[`backend_${ctx.error.code}` as keyof typeof m] as () => string

View File

@@ -0,0 +1,78 @@
import { cva, type VariantProps } from 'class-variance-authority';
import * as React from 'react';
import { cn } from '@/lib/utils';
const alertVariants = cva(
"grid gap-0.5 rounded-lg border px-2 py-1.5 text-left text-xs/relaxed has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-1.5 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-3.5 w-full relative group/alert",
{
variants: {
variant: {
default: 'bg-card text-card-foreground',
destructive:
'text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current',
warning:
'text-yellow-500 bg-yellow-50/50 *:data-[slot=alert-description]:text-yellow-500',
},
},
defaultVariants: {
variant: 'default',
},
},
);
function Alert({
className,
variant,
...props
}: React.ComponentProps<'div'> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
);
}
function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="alert-title"
className={cn(
'font-medium group-has-[>svg]/alert:col-start-2 [&_a]:hover:text-foreground [&_a]:underline [&_a]:underline-offset-3',
className,
)}
{...props}
/>
);
}
function AlertDescription({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot="alert-description"
className={cn(
'text-muted-foreground text-xs/relaxed text-balance md:text-pretty [&_p:not(:last-child)]:mb-4 [&_a]:hover:text-foreground [&_a]:underline [&_a]:underline-offset-3',
className,
)}
{...props}
/>
);
}
function AlertAction({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="alert-action"
className={cn('absolute top-1.5 right-2', className)}
{...props}
/>
);
}
export { Alert, AlertAction, AlertDescription, AlertTitle };

View File

@@ -1,32 +1,32 @@
import * as React from "react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { Dialog as DialogPrimitive } from 'radix-ui';
import * as React from 'react';
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "@phosphor-icons/react"
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { XIcon } from '@phosphor-icons/react';
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
@@ -36,10 +36,13 @@ function DialogOverlay({
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/80 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 isolate z-50", className)}
className={cn(
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/80 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 isolate z-50',
className,
)}
{...props}
/>
)
);
}
function DialogContent({
@@ -48,7 +51,7 @@ function DialogContent({
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
showCloseButton?: boolean;
}) {
return (
<DialogPortal>
@@ -56,34 +59,37 @@ function DialogContent({
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 ring-foreground/10 grid max-w-[calc(100%-2rem)] gap-4 rounded-xl p-4 text-xs/relaxed ring-1 duration-100 sm:max-w-sm fixed top-1/2 left-1/2 z-50 w-full -translate-x-1/2 -translate-y-1/2",
className
'bg-background data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 ring-foreground/10 grid max-w-[calc(100%-2rem)] gap-4 rounded-xl p-4 text-xs/relaxed ring-1 duration-100 sm:max-w-sm fixed top-1/2 left-1/2 z-50 w-full -translate-x-1/2 -translate-y-1/2',
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close data-slot="dialog-close" asChild>
<Button variant="ghost" className="absolute top-2 right-2" size="icon-sm">
<XIcon
/>
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
>
<XIcon />
<span className="sr-only">Close</span>
</Button>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="dialog-header"
className={cn("gap-1 flex flex-col", className)}
className={cn('gap-1 flex flex-col', className)}
{...props}
/>
)
);
}
function DialogFooter({
@@ -91,15 +97,15 @@ function DialogFooter({
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}: React.ComponentProps<'div'> & {
showCloseButton?: boolean;
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"gap-2 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
'gap-2 flex flex-col-reverse sm:flex-row sm:justify-end',
className,
)}
{...props}
>
@@ -110,7 +116,7 @@ function DialogFooter({
</DialogPrimitive.Close>
)}
</div>
)
);
}
function DialogTitle({
@@ -120,10 +126,10 @@ function DialogTitle({
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-sm font-medium", className)}
className={cn('text-sm font-medium', className)}
{...props}
/>
)
);
}
function DialogDescription({
@@ -133,10 +139,13 @@ function DialogDescription({
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground *:[a]:hover:text-foreground text-xs/relaxed *:[a]:underline *:[a]:underline-offset-3", className)}
className={cn(
'text-muted-foreground *:[a]:hover:text-foreground text-xs/relaxed *:[a]:underline *:[a]:underline-offset-3',
className,
)}
{...props}
/>
)
);
}
export {
@@ -150,4 +159,4 @@ export {
DialogPortal,
DialogTitle,
DialogTrigger,
}
};

View File

@@ -8,12 +8,12 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
type={type}
data-slot="input"
className={cn(
"bg-input/20 dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 h-7 rounded-md border px-2 py-0.5 text-sm transition-colors file:h-6 file:text-xs/relaxed file:font-medium focus-visible:ring-[2px] aria-invalid:ring-[2px] md:text-xs/relaxed file:text-foreground placeholder:text-muted-foreground w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
className
'bg-input/20 dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 h-7 rounded-md border px-2 py-0.5 text-sm transition-colors file:h-6 file:text-xs/relaxed file:font-medium focus-visible:ring-2 aria-invalid:ring-2 md:text-xs/relaxed file:text-foreground placeholder:text-muted-foreground w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
/>
)
);
}
export { Input }

View File

@@ -0,0 +1,43 @@
import { MagnifyingGlassIcon, XIcon } from '@phosphor-icons/react';
import { Button } from './button';
import { InputGroup, InputGroupAddon, InputGroupInput } from './input-group';
type SearchInputProps = {
keywords: string;
setKeyword: (value: string) => void;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
};
const SearchInput = ({ keywords, setKeyword, onChange }: SearchInputProps) => {
const onClearSearch = () => {
setKeyword('');
};
return (
<InputGroup className="w-70">
<InputGroupInput
id="keywords"
placeholder="Search...."
value={keywords}
onChange={onChange}
/>
<InputGroupAddon>
<MagnifyingGlassIcon />
</InputGroupAddon>
<InputGroupAddon align="inline-end">
{keywords !== '' && (
<Button
variant="ghost"
size="icon-sm"
className="rounded-full"
onClick={onClearSearch}
>
<XIcon />
</Button>
)}
</InputGroupAddon>
</InputGroup>
);
};
export default SearchInput;

View File

@@ -0,0 +1,67 @@
import usePreventAutoFocus from '@/hooks/use-prevent-auto-focus';
import { m } from '@/paraglide/messages';
import { UserGearIcon } from '@phosphor-icons/react';
import { UserWithRole } from 'better-auth/plugins';
import { useState } from 'react';
import AdminSetUserRoleForm from '../form/admin-set-user-role-form';
import { Button } from '../ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '../ui/dialog';
import { Label } from '../ui/label';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
type SetRoleProps = {
data: UserWithRole;
};
const ChangeRoleAction = ({ data }: SetRoleProps) => {
const [_open, _setOpen] = useState(false);
const prevent = usePreventAutoFocus();
return (
<Dialog open={_open} onOpenChange={_setOpen}>
<Tooltip>
<TooltipTrigger asChild>
<DialogTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="rounded-full cursor-pointer text-yellow-500 hover:bg-yellow-100 hover:text-yellow-600"
>
<UserGearIcon size={16} />
<span className="sr-only">{m.ui_change_role_btn()}</span>
</Button>
</DialogTrigger>
</TooltipTrigger>
<TooltipContent className="bg-yellow-500 [&_svg]:bg-yellow-500 [&_svg]:fill-yellow-500 text-white">
<Label>{m.ui_change_role_btn()}</Label>
</TooltipContent>
<DialogContent
className="max-w-100 xl:max-w-2xl"
{...prevent}
onPointerDownOutside={(e) => e.preventDefault()}
>
<DialogHeader>
<DialogTitle className="flex items-center gap-3 text-lg font-bold text-yellow-600">
<UserGearIcon size={16} />
{m.ui_change_role_btn()}
</DialogTitle>
<DialogDescription className="sr-only">
{m.ui_change_role_btn()}
</DialogDescription>
</DialogHeader>
<AdminSetUserRoleForm data={data} onSubmit={_setOpen} />
</DialogContent>
</Tooltip>
</Dialog>
);
};
export default ChangeRoleAction;

View File

@@ -0,0 +1,67 @@
import usePreventAutoFocus from '@/hooks/use-prevent-auto-focus';
import { m } from '@/paraglide/messages';
import { LockIcon } from '@phosphor-icons/react';
import { UserWithRole } from 'better-auth/plugins';
import { useState } from 'react';
import BanUserForm from '../form/admin-ban-user-form';
import { Button } from '../ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '../ui/dialog';
import { Label } from '../ui/label';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
type ChangeUserStatusProps = {
data: UserWithRole;
};
const ChangeUserStatusAction = ({ data }: ChangeUserStatusProps) => {
const [_open, _setOpen] = useState(false);
const prevent = usePreventAutoFocus();
return (
<Dialog open={_open} onOpenChange={_setOpen}>
<Tooltip>
<TooltipTrigger asChild>
<DialogTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="rounded-full cursor-pointer text-red-500 hover:bg-red-100 hover:text-red-600"
>
<LockIcon size={16} />
<span className="sr-only">{m.ui_ban_btn()}</span>
</Button>
</DialogTrigger>
</TooltipTrigger>
<TooltipContent className="bg-red-500 [&_svg]:bg-red-500 [&_svg]:fill-red-500 text-white">
<Label>{m.ui_ban_btn()}</Label>
</TooltipContent>
<DialogContent
className="max-w-100 xl:max-w-2xl"
{...prevent}
onPointerDownOutside={(e) => e.preventDefault()}
>
<DialogHeader>
<DialogTitle className="flex items-center gap-3 text-lg font-bold text-red-600">
<LockIcon size={16} />
{m.ui_ban_btn()}
</DialogTitle>
<DialogDescription className="sr-only">
{m.ui_change_role_btn()}
</DialogDescription>
</DialogHeader>
<BanUserForm data={data} onSubmit={_setOpen} />
</DialogContent>
</Tooltip>
</Dialog>
);
};
export default ChangeUserStatusAction;

View File

@@ -0,0 +1,66 @@
import usePreventAutoFocus from '@/hooks/use-prevent-auto-focus';
import { m } from '@/paraglide/messages';
import { PenIcon } from '@phosphor-icons/react';
import { UserWithRole } from 'better-auth/plugins';
import { useState } from 'react';
import AdminUpdateUserInfoForm from '../form/admin-update-user-info-form';
import { Button } from '../ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '../ui/dialog';
import { Label } from '../ui/label';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
type EditUserProps = {
data: UserWithRole;
};
const EditUserAction = ({ data }: EditUserProps) => {
const [_open, _setOpen] = useState(false);
const prevent = usePreventAutoFocus();
return (
<Dialog open={_open} onOpenChange={_setOpen}>
<Tooltip>
<TooltipTrigger asChild>
<DialogTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="rounded-full cursor-pointer text-blue-500 hover:bg-blue-100 hover:text-blue-600"
>
<PenIcon size={16} />
<span className="sr-only">{m.ui_edit_user_btn()}</span>
</Button>
</DialogTrigger>
</TooltipTrigger>
<TooltipContent className="bg-blue-500 [&_svg]:bg-blue-500 [&_svg]:fill-blue-500 text-white">
<Label>{m.ui_edit_user_btn()}</Label>
</TooltipContent>
<DialogContent
className="max-w-100 xl:max-w-2xl"
{...prevent}
onPointerDownOutside={(e) => e.preventDefault()}
>
<DialogHeader>
<DialogTitle className="flex items-center gap-3 text-lg font-bold text-blue-600">
<PenIcon size={16} /> {m.ui_edit_user_btn()}
</DialogTitle>
<DialogDescription className="sr-only">
{m.ui_edit_user_btn()}
</DialogDescription>
</DialogHeader>
<AdminUpdateUserInfoForm data={data} onSubmit={_setOpen} />
</DialogContent>
</Tooltip>
</Dialog>
);
};
export default EditUserAction;

View File

@@ -0,0 +1,67 @@
import usePreventAutoFocus from '@/hooks/use-prevent-auto-focus';
import { m } from '@/paraglide/messages';
import { KeyIcon } from '@phosphor-icons/react';
import { UserWithRole } from 'better-auth/plugins';
import { useState } from 'react';
import AdminSetPasswordForm from '../form/admin-set-password-form';
import { Button } from '../ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '../ui/dialog';
import { Label } from '../ui/label';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
type UpdatePasswordProps = {
data: UserWithRole;
};
const SetPasswordAction = ({ data }: UpdatePasswordProps) => {
const [_open, _setOpen] = useState(false);
const prevent = usePreventAutoFocus();
return (
<Dialog open={_open} onOpenChange={_setOpen}>
<Tooltip>
<TooltipTrigger asChild>
<DialogTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="rounded-full cursor-pointer text-stone-500 hover:bg-stone-100 hover:text-stone-600"
>
<KeyIcon size={16} />
<span className="sr-only">{m.ui_update_password_btn()}</span>
</Button>
</DialogTrigger>
</TooltipTrigger>
<TooltipContent className="bg-stone-500 [&_svg]:bg-stone-500 [&_svg]:fill-stone-500 text-white">
<Label>{m.ui_update_password_btn()}</Label>
</TooltipContent>
<DialogContent
className="max-w-100 xl:max-w-2xl"
{...prevent}
onPointerDownOutside={(e) => e.preventDefault()}
>
<DialogHeader>
<DialogTitle className="flex items-center gap-3 text-lg font-bold text-stone-600">
<KeyIcon size={20} />
{m.ui_update_password_btn()}
</DialogTitle>
<DialogDescription className="sr-only">
{m.ui_update_password_btn()}
</DialogDescription>
</DialogHeader>
<AdminSetPasswordForm data={data} onSubmit={_setOpen} />
</DialogContent>
</Tooltip>
</Dialog>
);
};
export default SetPasswordAction;

View File

@@ -0,0 +1,74 @@
import { m } from '@/paraglide/messages';
import { formatters } from '@/utils/formatters';
import { CheckCircleIcon, XCircleIcon } from '@phosphor-icons/react';
import { ColumnDef } from '@tanstack/react-table';
import { UserWithRole } from 'better-auth/plugins';
import RoleBadge from '../avatar/role-badge';
import ChangeRoleAction from './change-role-dialog';
import ChangeUserStatusAction from './change-user-status-dialog';
import EditUserAction from './edit-user-dialog';
import SetPasswordAction from './set-password-dialog';
export const userColumns: ColumnDef<UserWithRole>[] = [
{
accessorKey: 'name',
header: m.users_page_ui_table_header_name(),
meta: {
thClass: 'w-1/6',
},
},
{
accessorKey: 'email',
header: m.users_page_ui_table_header_email(),
meta: {
thClass: 'w-1/6',
},
},
{
accessorKey: 'role',
header: m.users_page_ui_table_header_role(),
meta: {
thClass: 'w-1/6',
},
cell: ({ row }) => <RoleBadge type={row.original.role} />,
},
{
accessorKey: 'banned',
header: m.users_page_ui_table_header_banned(),
meta: {
thClass: 'w-1/6',
},
cell: ({ row }) =>
row.original.banned ? (
<CheckCircleIcon size={18} weight="fill" className="text-green-400" />
) : (
<XCircleIcon size={18} weight="fill" className="text-red-400" />
),
},
{
accessorKey: 'createdAt',
header: m.users_page_ui_table_header_created_at(),
meta: {
thClass: 'w-1/6',
},
cell: ({ row }) => {
return formatters.dateTime(new Date(row.original.createdAt));
},
},
{
id: 'actions',
meta: {
thClass: 'w-1/6',
},
cell: ({ row }) => {
return (
<div className="flex justify-end gap-2">
<EditUserAction data={row.original} />
<SetPasswordAction data={row.original} />
<ChangeRoleAction data={row.original} />
<ChangeUserStatusAction data={row.original} />
</div>
);
},
},
];