Added User List table
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
115
src/components/audit/view-detail-dialog.tsx
Normal file
115
src/components/audit/view-detail-dialog.tsx
Normal 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;
|
||||
193
src/components/form/admin-ban-user-form.tsx
Normal file
193
src/components/form/admin-ban-user-form.tsx
Normal 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;
|
||||
124
src/components/form/admin-set-password-form.tsx
Normal file
124
src/components/form/admin-set-password-form.tsx
Normal 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;
|
||||
146
src/components/form/admin-set-user-role-form.tsx
Normal file
146
src/components/form/admin-set-user-role-form.tsx
Normal 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;
|
||||
123
src/components/form/admin-update-user-info-form.tsx
Normal file
123
src/components/form/admin-update-user-info-form.tsx
Normal 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;
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
78
src/components/ui/alert.tsx
Normal file
78
src/components/ui/alert.tsx
Normal 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 };
|
||||
@@ -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,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 }
|
||||
|
||||
43
src/components/ui/search-input.tsx
Normal file
43
src/components/ui/search-input.tsx
Normal 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;
|
||||
67
src/components/user/change-role-dialog.tsx
Normal file
67
src/components/user/change-role-dialog.tsx
Normal 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;
|
||||
67
src/components/user/change-user-status-dialog.tsx
Normal file
67
src/components/user/change-user-status-dialog.tsx
Normal 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;
|
||||
66
src/components/user/edit-user-dialog.tsx
Normal file
66
src/components/user/edit-user-dialog.tsx
Normal 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;
|
||||
67
src/components/user/set-password-dialog.tsx
Normal file
67
src/components/user/set-password-dialog.tsx
Normal 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;
|
||||
74
src/components/user/user-column.tsx
Normal file
74
src/components/user/user-column.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
14
src/hooks/use-prevent-auto-focus.ts
Normal file
14
src/hooks/use-prevent-auto-focus.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
|
||||
function usePreventAutoFocus<E extends HTMLElement = HTMLDivElement>() {
|
||||
const ref = useRef<E>(null);
|
||||
|
||||
const onOpenAutoFocus = useCallback((event: Event) => {
|
||||
event.preventDefault();
|
||||
ref.current?.focus({ preventScroll: true });
|
||||
}, []);
|
||||
|
||||
return { ref, onOpenAutoFocus, tabIndex: -1 as const };
|
||||
}
|
||||
|
||||
export default usePreventAutoFocus;
|
||||
@@ -1,29 +1,29 @@
|
||||
import { defaultStatements, adminAc } from 'better-auth/plugins/admin/access'
|
||||
import { createAccessControl } from 'better-auth/plugins/access'
|
||||
import { createAccessControl } from 'better-auth/plugins/access';
|
||||
import { adminAc, defaultStatements } from 'better-auth/plugins/admin/access';
|
||||
|
||||
const statement = {
|
||||
...defaultStatements,
|
||||
audit: ['list'],
|
||||
setting: ['list', 'create', 'update', 'delete'],
|
||||
setting: ['list', 'update'],
|
||||
house: ['list', 'create', 'update', 'delete'],
|
||||
box: ['list', 'create', 'update', 'delete'],
|
||||
item: ['list', 'create', 'update', 'delete'],
|
||||
} as const
|
||||
} as const;
|
||||
|
||||
const ac = createAccessControl(statement)
|
||||
const ac = createAccessControl(statement);
|
||||
|
||||
const admin = ac.newRole({
|
||||
...adminAc.statements,
|
||||
audit: ['list'],
|
||||
setting: ['list', 'create', 'update', 'delete'],
|
||||
setting: ['list', 'update'],
|
||||
house: ['list', 'create', 'update', 'delete'],
|
||||
box: ['list', 'create', 'update', 'delete'],
|
||||
item: ['list', 'create', 'update', 'delete'],
|
||||
})
|
||||
});
|
||||
|
||||
const user = ac.newRole({
|
||||
setting: ['list', 'update'],
|
||||
house: ['list', 'create', 'update', 'delete'],
|
||||
})
|
||||
});
|
||||
|
||||
export { ac, admin, user }
|
||||
export { ac, admin, user };
|
||||
|
||||
@@ -15,11 +15,14 @@ import { Route as authSignUpRouteImport } from './routes/(auth)/sign-up'
|
||||
import { Route as authSignInRouteImport } from './routes/(auth)/sign-in'
|
||||
import { Route as appauthRouteRouteImport } from './routes/(app)/(auth)/route'
|
||||
import { Route as ApiAuthSplatRouteImport } from './routes/api.auth.$'
|
||||
import { Route as appauthSettingsRouteImport } from './routes/(app)/(auth)/settings'
|
||||
import { Route as appauthLogsRouteImport } from './routes/(app)/(auth)/logs'
|
||||
import { Route as appauthDashboardRouteImport } from './routes/(app)/(auth)/dashboard'
|
||||
import { Route as appauthKanriRouteRouteImport } from './routes/(app)/(auth)/kanri/route'
|
||||
import { Route as appauthAccountRouteRouteImport } from './routes/(app)/(auth)/account/route'
|
||||
import { Route as appauthKanriIndexRouteImport } from './routes/(app)/(auth)/kanri/index'
|
||||
import { Route as appauthAccountIndexRouteImport } from './routes/(app)/(auth)/account/index'
|
||||
import { Route as appauthKanriUsersRouteImport } from './routes/(app)/(auth)/kanri/users'
|
||||
import { Route as appauthKanriSettingsRouteImport } from './routes/(app)/(auth)/kanri/settings'
|
||||
import { Route as appauthKanriLogsRouteImport } from './routes/(app)/(auth)/kanri/logs'
|
||||
import { Route as appauthAccountSettingsRouteImport } from './routes/(app)/(auth)/account/settings'
|
||||
import { Route as appauthAccountProfileRouteImport } from './routes/(app)/(auth)/account/profile'
|
||||
import { Route as appauthAccountChangePasswordRouteImport } from './routes/(app)/(auth)/account/change-password'
|
||||
@@ -52,31 +55,46 @@ const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({
|
||||
path: '/api/auth/$',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const appauthSettingsRoute = appauthSettingsRouteImport.update({
|
||||
id: '/settings',
|
||||
path: '/settings',
|
||||
getParentRoute: () => appauthRouteRoute,
|
||||
} as any)
|
||||
const appauthLogsRoute = appauthLogsRouteImport.update({
|
||||
id: '/logs',
|
||||
path: '/logs',
|
||||
getParentRoute: () => appauthRouteRoute,
|
||||
} as any)
|
||||
const appauthDashboardRoute = appauthDashboardRouteImport.update({
|
||||
id: '/dashboard',
|
||||
path: '/dashboard',
|
||||
getParentRoute: () => appauthRouteRoute,
|
||||
} as any)
|
||||
const appauthKanriRouteRoute = appauthKanriRouteRouteImport.update({
|
||||
id: '/kanri',
|
||||
path: '/kanri',
|
||||
getParentRoute: () => appauthRouteRoute,
|
||||
} as any)
|
||||
const appauthAccountRouteRoute = appauthAccountRouteRouteImport.update({
|
||||
id: '/account',
|
||||
path: '/account',
|
||||
getParentRoute: () => appauthRouteRoute,
|
||||
} as any)
|
||||
const appauthKanriIndexRoute = appauthKanriIndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => appauthKanriRouteRoute,
|
||||
} as any)
|
||||
const appauthAccountIndexRoute = appauthAccountIndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => appauthAccountRouteRoute,
|
||||
} as any)
|
||||
const appauthKanriUsersRoute = appauthKanriUsersRouteImport.update({
|
||||
id: '/users',
|
||||
path: '/users',
|
||||
getParentRoute: () => appauthKanriRouteRoute,
|
||||
} as any)
|
||||
const appauthKanriSettingsRoute = appauthKanriSettingsRouteImport.update({
|
||||
id: '/settings',
|
||||
path: '/settings',
|
||||
getParentRoute: () => appauthKanriRouteRoute,
|
||||
} as any)
|
||||
const appauthKanriLogsRoute = appauthKanriLogsRouteImport.update({
|
||||
id: '/logs',
|
||||
path: '/logs',
|
||||
getParentRoute: () => appauthKanriRouteRoute,
|
||||
} as any)
|
||||
const appauthAccountSettingsRoute = appauthAccountSettingsRouteImport.update({
|
||||
id: '/settings',
|
||||
path: '/settings',
|
||||
@@ -99,27 +117,32 @@ export interface FileRoutesByFullPath {
|
||||
'/sign-up': typeof authSignUpRoute
|
||||
'/': typeof appIndexRoute
|
||||
'/account': typeof appauthAccountRouteRouteWithChildren
|
||||
'/kanri': typeof appauthKanriRouteRouteWithChildren
|
||||
'/dashboard': typeof appauthDashboardRoute
|
||||
'/logs': typeof appauthLogsRoute
|
||||
'/settings': typeof appauthSettingsRoute
|
||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||
'/account/change-password': typeof appauthAccountChangePasswordRoute
|
||||
'/account/profile': typeof appauthAccountProfileRoute
|
||||
'/account/settings': typeof appauthAccountSettingsRoute
|
||||
'/kanri/logs': typeof appauthKanriLogsRoute
|
||||
'/kanri/settings': typeof appauthKanriSettingsRoute
|
||||
'/kanri/users': typeof appauthKanriUsersRoute
|
||||
'/account/': typeof appauthAccountIndexRoute
|
||||
'/kanri/': typeof appauthKanriIndexRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/sign-in': typeof authSignInRoute
|
||||
'/sign-up': typeof authSignUpRoute
|
||||
'/': typeof appIndexRoute
|
||||
'/dashboard': typeof appauthDashboardRoute
|
||||
'/logs': typeof appauthLogsRoute
|
||||
'/settings': typeof appauthSettingsRoute
|
||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||
'/account/change-password': typeof appauthAccountChangePasswordRoute
|
||||
'/account/profile': typeof appauthAccountProfileRoute
|
||||
'/account/settings': typeof appauthAccountSettingsRoute
|
||||
'/kanri/logs': typeof appauthKanriLogsRoute
|
||||
'/kanri/settings': typeof appauthKanriSettingsRoute
|
||||
'/kanri/users': typeof appauthKanriUsersRoute
|
||||
'/account': typeof appauthAccountIndexRoute
|
||||
'/kanri': typeof appauthKanriIndexRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
@@ -129,14 +152,17 @@ export interface FileRoutesById {
|
||||
'/(auth)/sign-up': typeof authSignUpRoute
|
||||
'/(app)/': typeof appIndexRoute
|
||||
'/(app)/(auth)/account': typeof appauthAccountRouteRouteWithChildren
|
||||
'/(app)/(auth)/kanri': typeof appauthKanriRouteRouteWithChildren
|
||||
'/(app)/(auth)/dashboard': typeof appauthDashboardRoute
|
||||
'/(app)/(auth)/logs': typeof appauthLogsRoute
|
||||
'/(app)/(auth)/settings': typeof appauthSettingsRoute
|
||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||
'/(app)/(auth)/account/change-password': typeof appauthAccountChangePasswordRoute
|
||||
'/(app)/(auth)/account/profile': typeof appauthAccountProfileRoute
|
||||
'/(app)/(auth)/account/settings': typeof appauthAccountSettingsRoute
|
||||
'/(app)/(auth)/kanri/logs': typeof appauthKanriLogsRoute
|
||||
'/(app)/(auth)/kanri/settings': typeof appauthKanriSettingsRoute
|
||||
'/(app)/(auth)/kanri/users': typeof appauthKanriUsersRoute
|
||||
'/(app)/(auth)/account/': typeof appauthAccountIndexRoute
|
||||
'/(app)/(auth)/kanri/': typeof appauthKanriIndexRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
@@ -145,27 +171,32 @@ export interface FileRouteTypes {
|
||||
| '/sign-up'
|
||||
| '/'
|
||||
| '/account'
|
||||
| '/kanri'
|
||||
| '/dashboard'
|
||||
| '/logs'
|
||||
| '/settings'
|
||||
| '/api/auth/$'
|
||||
| '/account/change-password'
|
||||
| '/account/profile'
|
||||
| '/account/settings'
|
||||
| '/kanri/logs'
|
||||
| '/kanri/settings'
|
||||
| '/kanri/users'
|
||||
| '/account/'
|
||||
| '/kanri/'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/sign-in'
|
||||
| '/sign-up'
|
||||
| '/'
|
||||
| '/dashboard'
|
||||
| '/logs'
|
||||
| '/settings'
|
||||
| '/api/auth/$'
|
||||
| '/account/change-password'
|
||||
| '/account/profile'
|
||||
| '/account/settings'
|
||||
| '/kanri/logs'
|
||||
| '/kanri/settings'
|
||||
| '/kanri/users'
|
||||
| '/account'
|
||||
| '/kanri'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/(app)'
|
||||
@@ -174,14 +205,17 @@ export interface FileRouteTypes {
|
||||
| '/(auth)/sign-up'
|
||||
| '/(app)/'
|
||||
| '/(app)/(auth)/account'
|
||||
| '/(app)/(auth)/kanri'
|
||||
| '/(app)/(auth)/dashboard'
|
||||
| '/(app)/(auth)/logs'
|
||||
| '/(app)/(auth)/settings'
|
||||
| '/api/auth/$'
|
||||
| '/(app)/(auth)/account/change-password'
|
||||
| '/(app)/(auth)/account/profile'
|
||||
| '/(app)/(auth)/account/settings'
|
||||
| '/(app)/(auth)/kanri/logs'
|
||||
| '/(app)/(auth)/kanri/settings'
|
||||
| '/(app)/(auth)/kanri/users'
|
||||
| '/(app)/(auth)/account/'
|
||||
| '/(app)/(auth)/kanri/'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
@@ -235,20 +269,6 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof ApiAuthSplatRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/(app)/(auth)/settings': {
|
||||
id: '/(app)/(auth)/settings'
|
||||
path: '/settings'
|
||||
fullPath: '/settings'
|
||||
preLoaderRoute: typeof appauthSettingsRouteImport
|
||||
parentRoute: typeof appauthRouteRoute
|
||||
}
|
||||
'/(app)/(auth)/logs': {
|
||||
id: '/(app)/(auth)/logs'
|
||||
path: '/logs'
|
||||
fullPath: '/logs'
|
||||
preLoaderRoute: typeof appauthLogsRouteImport
|
||||
parentRoute: typeof appauthRouteRoute
|
||||
}
|
||||
'/(app)/(auth)/dashboard': {
|
||||
id: '/(app)/(auth)/dashboard'
|
||||
path: '/dashboard'
|
||||
@@ -256,6 +276,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof appauthDashboardRouteImport
|
||||
parentRoute: typeof appauthRouteRoute
|
||||
}
|
||||
'/(app)/(auth)/kanri': {
|
||||
id: '/(app)/(auth)/kanri'
|
||||
path: '/kanri'
|
||||
fullPath: '/kanri'
|
||||
preLoaderRoute: typeof appauthKanriRouteRouteImport
|
||||
parentRoute: typeof appauthRouteRoute
|
||||
}
|
||||
'/(app)/(auth)/account': {
|
||||
id: '/(app)/(auth)/account'
|
||||
path: '/account'
|
||||
@@ -263,6 +290,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof appauthAccountRouteRouteImport
|
||||
parentRoute: typeof appauthRouteRoute
|
||||
}
|
||||
'/(app)/(auth)/kanri/': {
|
||||
id: '/(app)/(auth)/kanri/'
|
||||
path: '/'
|
||||
fullPath: '/kanri/'
|
||||
preLoaderRoute: typeof appauthKanriIndexRouteImport
|
||||
parentRoute: typeof appauthKanriRouteRoute
|
||||
}
|
||||
'/(app)/(auth)/account/': {
|
||||
id: '/(app)/(auth)/account/'
|
||||
path: '/'
|
||||
@@ -270,6 +304,27 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof appauthAccountIndexRouteImport
|
||||
parentRoute: typeof appauthAccountRouteRoute
|
||||
}
|
||||
'/(app)/(auth)/kanri/users': {
|
||||
id: '/(app)/(auth)/kanri/users'
|
||||
path: '/users'
|
||||
fullPath: '/kanri/users'
|
||||
preLoaderRoute: typeof appauthKanriUsersRouteImport
|
||||
parentRoute: typeof appauthKanriRouteRoute
|
||||
}
|
||||
'/(app)/(auth)/kanri/settings': {
|
||||
id: '/(app)/(auth)/kanri/settings'
|
||||
path: '/settings'
|
||||
fullPath: '/kanri/settings'
|
||||
preLoaderRoute: typeof appauthKanriSettingsRouteImport
|
||||
parentRoute: typeof appauthKanriRouteRoute
|
||||
}
|
||||
'/(app)/(auth)/kanri/logs': {
|
||||
id: '/(app)/(auth)/kanri/logs'
|
||||
path: '/logs'
|
||||
fullPath: '/kanri/logs'
|
||||
preLoaderRoute: typeof appauthKanriLogsRouteImport
|
||||
parentRoute: typeof appauthKanriRouteRoute
|
||||
}
|
||||
'/(app)/(auth)/account/settings': {
|
||||
id: '/(app)/(auth)/account/settings'
|
||||
path: '/settings'
|
||||
@@ -311,18 +366,33 @@ const appauthAccountRouteRouteChildren: appauthAccountRouteRouteChildren = {
|
||||
const appauthAccountRouteRouteWithChildren =
|
||||
appauthAccountRouteRoute._addFileChildren(appauthAccountRouteRouteChildren)
|
||||
|
||||
interface appauthKanriRouteRouteChildren {
|
||||
appauthKanriLogsRoute: typeof appauthKanriLogsRoute
|
||||
appauthKanriSettingsRoute: typeof appauthKanriSettingsRoute
|
||||
appauthKanriUsersRoute: typeof appauthKanriUsersRoute
|
||||
appauthKanriIndexRoute: typeof appauthKanriIndexRoute
|
||||
}
|
||||
|
||||
const appauthKanriRouteRouteChildren: appauthKanriRouteRouteChildren = {
|
||||
appauthKanriLogsRoute: appauthKanriLogsRoute,
|
||||
appauthKanriSettingsRoute: appauthKanriSettingsRoute,
|
||||
appauthKanriUsersRoute: appauthKanriUsersRoute,
|
||||
appauthKanriIndexRoute: appauthKanriIndexRoute,
|
||||
}
|
||||
|
||||
const appauthKanriRouteRouteWithChildren =
|
||||
appauthKanriRouteRoute._addFileChildren(appauthKanriRouteRouteChildren)
|
||||
|
||||
interface appauthRouteRouteChildren {
|
||||
appauthAccountRouteRoute: typeof appauthAccountRouteRouteWithChildren
|
||||
appauthKanriRouteRoute: typeof appauthKanriRouteRouteWithChildren
|
||||
appauthDashboardRoute: typeof appauthDashboardRoute
|
||||
appauthLogsRoute: typeof appauthLogsRoute
|
||||
appauthSettingsRoute: typeof appauthSettingsRoute
|
||||
}
|
||||
|
||||
const appauthRouteRouteChildren: appauthRouteRouteChildren = {
|
||||
appauthAccountRouteRoute: appauthAccountRouteRouteWithChildren,
|
||||
appauthKanriRouteRoute: appauthKanriRouteRouteWithChildren,
|
||||
appauthDashboardRoute: appauthDashboardRoute,
|
||||
appauthLogsRoute: appauthLogsRoute,
|
||||
appauthSettingsRoute: appauthSettingsRoute,
|
||||
}
|
||||
|
||||
const appauthRouteRouteWithChildren = appauthRouteRoute._addFileChildren(
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute('/(app)/(auth)/account/')({
|
||||
component: RouteComponent,
|
||||
beforeLoad: () => {
|
||||
throw redirect({ to: '/' });
|
||||
},
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
return <div>Hello "/(app)/(auth)/account/"!</div>;
|
||||
}
|
||||
|
||||
15
src/routes/(app)/(auth)/kanri/index.tsx
Normal file
15
src/routes/(app)/(auth)/kanri/index.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute('/(app)/(auth)/kanri/')({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
return (
|
||||
<div className="@container/main flex flex-1 flex-col gap-2 p-4">
|
||||
<div className="*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card *:data-[slot=card]:bg-linear-to-br *:data-[slot=card]:shadow-xs grid grid-cols-1 @xl/main:grid-cols-2 @5xl/main:grid-cols-3 gap-4">
|
||||
Hello Admin!
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +1,19 @@
|
||||
import { logColumns } from '@/components/audit/audit-columns';
|
||||
import DataTable from '@/components/DataTable';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
} from '@/components/ui/input-group';
|
||||
import SearchInput from '@/components/ui/search-input';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import useDebounced from '@/hooks/use-debounced';
|
||||
import { m } from '@/paraglide/messages';
|
||||
import { auditQueries } from '@/service/queries';
|
||||
import {
|
||||
CircuitryIcon,
|
||||
MagnifyingGlassIcon,
|
||||
XIcon,
|
||||
} from '@phosphor-icons/react';
|
||||
import { CircuitryIcon } from '@phosphor-icons/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { useState } from 'react';
|
||||
|
||||
export const Route = createFileRoute('/(app)/(auth)/logs')({
|
||||
export const Route = createFileRoute('/(app)/(auth)/kanri/logs')({
|
||||
component: RouteComponent,
|
||||
staticData: { breadcrumb: () => m.nav_log() },
|
||||
staticData: { breadcrumb: () => m.nav_logs() },
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
@@ -42,9 +33,6 @@ function RouteComponent() {
|
||||
setSearchKeyword(e.target.value);
|
||||
setPage(1);
|
||||
};
|
||||
const onClearSearch = () => {
|
||||
setSearchKeyword('');
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -68,32 +56,14 @@ function RouteComponent() {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<div className="flex">
|
||||
<InputGroup className="w-70">
|
||||
<InputGroupInput
|
||||
id="keywords"
|
||||
placeholder="Search...."
|
||||
value={searchKeyword}
|
||||
onChange={onSearchChange}
|
||||
/>
|
||||
<InputGroupAddon>
|
||||
<MagnifyingGlassIcon />
|
||||
</InputGroupAddon>
|
||||
<InputGroupAddon align="inline-end">
|
||||
{searchKeyword !== '' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="rounded-full"
|
||||
onClick={onClearSearch}
|
||||
>
|
||||
<XIcon />
|
||||
</Button>
|
||||
)}
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
<div className="flex items-center">
|
||||
<SearchInput
|
||||
keywords={searchKeyword}
|
||||
setKeyword={setSearchKeyword}
|
||||
onChange={onSearchChange}
|
||||
/>
|
||||
</div>
|
||||
{data && (
|
||||
{data?.result && (
|
||||
<DataTable
|
||||
data={data.result || []}
|
||||
columns={logColumns}
|
||||
10
src/routes/(app)/(auth)/kanri/route.tsx
Normal file
10
src/routes/(app)/(auth)/kanri/route.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute('/(app)/(auth)/kanri')({
|
||||
beforeLoad: ({ context }) => {
|
||||
if (!context.session?.user.role && context.session?.user.role !== 'admin') {
|
||||
throw redirect({ to: '/' });
|
||||
}
|
||||
},
|
||||
staticData: { breadcrumb: 'Kanri' },
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import SettingsForm from '@/components/form/settings-form';
|
||||
import { m } from '@/paraglide/messages';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute('/(app)/(auth)/settings')({
|
||||
export const Route = createFileRoute('/(app)/(auth)/kanri/settings')({
|
||||
component: RouteComponent,
|
||||
staticData: { breadcrumb: () => m.nav_settings() },
|
||||
});
|
||||
81
src/routes/(app)/(auth)/kanri/users.tsx
Normal file
81
src/routes/(app)/(auth)/kanri/users.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import DataTable from '@/components/DataTable';
|
||||
import { Card, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import SearchInput from '@/components/ui/search-input';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { userColumns } from '@/components/user/user-column';
|
||||
import useDebounced from '@/hooks/use-debounced';
|
||||
import { m } from '@/paraglide/messages';
|
||||
import { usersQueries } from '@/service/queries';
|
||||
import { UsersIcon } from '@phosphor-icons/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { useState } from 'react';
|
||||
|
||||
export const Route = createFileRoute('/(app)/(auth)/kanri/users')({
|
||||
component: RouteComponent,
|
||||
staticData: { breadcrumb: () => m.nav_users() },
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageLimit, setPageLimit] = useState(10);
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
const debouncedSearch = useDebounced(searchKeyword, 500);
|
||||
|
||||
const { data, isLoading } = useQuery(
|
||||
usersQueries.list({
|
||||
page,
|
||||
limit: pageLimit,
|
||||
keyword: debouncedSearch,
|
||||
}),
|
||||
);
|
||||
|
||||
const onSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchKeyword(e.target.value);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="@container/main flex flex-1 flex-col gap-2 p-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-130 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="@container/main flex flex-1 flex-col gap-2 p-4">
|
||||
<div className="*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card *:data-[slot=card]:bg-linear-to-br *:data-[slot=card]:shadow-xs flex flex-col gap-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl flex items-center gap-2">
|
||||
<UsersIcon size={24} />
|
||||
{m.users_page_ui_title()}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<div className="flex items-center">
|
||||
<SearchInput
|
||||
keywords={searchKeyword}
|
||||
setKeyword={setSearchKeyword}
|
||||
onChange={onSearchChange}
|
||||
/>
|
||||
</div>
|
||||
{data && (
|
||||
<DataTable
|
||||
data={data.result || []}
|
||||
columns={userColumns}
|
||||
page={page}
|
||||
setPage={setPage}
|
||||
limit={pageLimit}
|
||||
setLimit={setPageLimit}
|
||||
pagination={data.pagination}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { prisma } from '@/db';
|
||||
import { AuditModel, AuditWhereInput } from '@/generated/prisma/models';
|
||||
import { AuditWhereInput } from '@/generated/prisma/models';
|
||||
import { authMiddleware } from '@/lib/middleware';
|
||||
import { createServerFn } from '@tanstack/react-start';
|
||||
import { auditListSchema } from './audit.schema';
|
||||
@@ -33,7 +33,7 @@ export const getAllAudit = createServerFn({ method: 'GET' })
|
||||
},
|
||||
],
|
||||
};
|
||||
const [auditlog, total]: [AuditModel[], number] = await Promise.all([
|
||||
const [auditlog, total]: [AuditWithUser[], number] = await Promise.all([
|
||||
await prisma.audit.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
|
||||
@@ -5,5 +5,3 @@ export const auditListSchema = z.object({
|
||||
limit: z.coerce.number().min(10).max(100).default(10),
|
||||
keyword: z.string().optional(),
|
||||
});
|
||||
|
||||
export const auditSchema = z.object({});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { getSession } from '@/lib/auth/session';
|
||||
import { queryOptions } from '@tanstack/react-query';
|
||||
import { getAllAudit } from './audit.api';
|
||||
import { getAdminSettings, getUserSettings } from './setting.api';
|
||||
import { getAllUser } from './user.api';
|
||||
|
||||
export const sessionQueries = {
|
||||
all: ['auth'],
|
||||
@@ -36,3 +37,12 @@ export const auditQueries = {
|
||||
queryFn: () => getAllAudit({ data: params }),
|
||||
}),
|
||||
};
|
||||
|
||||
export const usersQueries = {
|
||||
all: ['users'],
|
||||
list: (params: { page: number; limit: number; keyword?: string }) =>
|
||||
queryOptions({
|
||||
queryKey: [...usersQueries.all, 'list', params],
|
||||
queryFn: () => getAllUser({ data: params }),
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -1,5 +1,44 @@
|
||||
import { prisma } from '@/db';
|
||||
import { Audit } from '@/generated/prisma/client';
|
||||
import { Audit, Setting } from '@/generated/prisma/client';
|
||||
|
||||
type AdminSettingValue = Pick<Setting, 'id' | 'key' | 'value'>;
|
||||
|
||||
type AdminSettingValueOnly = {
|
||||
[key: string]: string;
|
||||
};
|
||||
|
||||
type AdminSettingFull = {
|
||||
[key: string]: AdminSettingValue;
|
||||
};
|
||||
|
||||
export async function getAllAdminSettings(
|
||||
valueOnly: true,
|
||||
): Promise<AdminSettingValueOnly>;
|
||||
|
||||
export async function getAllAdminSettings(
|
||||
valueOnly?: false,
|
||||
): Promise<AdminSettingFull>;
|
||||
|
||||
export async function getAllAdminSettings(valueOnly = false) {
|
||||
const settings = await prisma.setting.findMany({
|
||||
where: {
|
||||
relation: 'admin',
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
key: true,
|
||||
value: true,
|
||||
},
|
||||
});
|
||||
|
||||
const results: Record<string, string | AdminSettingValue> = {};
|
||||
|
||||
settings.forEach((setting) => {
|
||||
results[setting.key] = valueOnly ? setting.value : setting;
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
export const createAuditLog = async (data: Omit<Audit, 'id' | 'createdAt'>) => {
|
||||
try {
|
||||
|
||||
@@ -1,36 +1,10 @@
|
||||
import { prisma } from '@/db';
|
||||
import { Setting } from '@/generated/prisma/client';
|
||||
import { authMiddleware } from '@/lib/middleware';
|
||||
import { extractDiffObjects } from '@/utils/help';
|
||||
import { extractDiffObjects } from '@/utils/helper';
|
||||
import { createServerFn } from '@tanstack/react-start';
|
||||
import { createAuditLog } from './repository';
|
||||
import { createAuditLog, getAllAdminSettings } from './repository';
|
||||
import { settingSchema, userSettingSchema } from './setting.schema';
|
||||
|
||||
type AdminSettingReturn = {
|
||||
[key: string]: Pick<Setting, 'id' | 'key' | 'value'> | string;
|
||||
};
|
||||
|
||||
async function getAllAdminSettings(valueOnly = false) {
|
||||
const settings = await prisma.setting.findMany({
|
||||
where: {
|
||||
relation: 'admin',
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
key: true,
|
||||
value: true,
|
||||
},
|
||||
});
|
||||
|
||||
const results: AdminSettingReturn = {};
|
||||
|
||||
settings.forEach((setting) => {
|
||||
results[setting.key] = valueOnly ? setting.value : setting;
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// Settings for admin
|
||||
export const getAdminSettings = createServerFn({ method: 'GET' })
|
||||
.middleware([authMiddleware])
|
||||
|
||||
133
src/service/user.api.ts
Normal file
133
src/service/user.api.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { auth } from '@/lib/auth';
|
||||
import { authMiddleware } from '@/lib/middleware';
|
||||
import { parseError } from '@/utils/helper';
|
||||
import { createServerFn } from '@tanstack/react-start';
|
||||
import { getRequestHeaders } from '@tanstack/react-start/server';
|
||||
import {
|
||||
userBanSchema,
|
||||
userListSchema,
|
||||
userSetPasswordSchema,
|
||||
userUpdateInfoSchema,
|
||||
userUpdateRoleSchema,
|
||||
} from './user.schema';
|
||||
|
||||
export const getAllUser = createServerFn({ method: 'GET' })
|
||||
.middleware([authMiddleware])
|
||||
.inputValidator(userListSchema)
|
||||
.handler(async ({ data }) => {
|
||||
const headers = getRequestHeaders();
|
||||
const { page, limit, keyword } = data;
|
||||
|
||||
const list = await auth.api.listUsers({
|
||||
query: {
|
||||
searchValue: keyword,
|
||||
searchField: 'name',
|
||||
searchOperator: 'contains',
|
||||
sortBy: 'createdAt',
|
||||
sortDirection: 'asc',
|
||||
limit,
|
||||
offset: (page - 1) * limit,
|
||||
},
|
||||
headers,
|
||||
});
|
||||
|
||||
const totalItem = list.total;
|
||||
const totalPage = Math.ceil(totalItem / limit);
|
||||
|
||||
return {
|
||||
result: list.users,
|
||||
pagination: {
|
||||
currentPage: page,
|
||||
totalPage,
|
||||
totalItem,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export const setUserPassword = createServerFn({ method: 'POST' })
|
||||
.middleware([authMiddleware])
|
||||
.inputValidator(userSetPasswordSchema)
|
||||
.handler(async ({ data }) => {
|
||||
try {
|
||||
const headers = getRequestHeaders();
|
||||
const result = await auth.api.setUserPassword({
|
||||
body: {
|
||||
newPassword: data.password,
|
||||
userId: data.id,
|
||||
},
|
||||
headers,
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
const { message, code } = parseError(error);
|
||||
throw { message, code };
|
||||
}
|
||||
});
|
||||
|
||||
export const updateUserInformation = createServerFn({ method: 'POST' })
|
||||
.middleware([authMiddleware])
|
||||
.inputValidator(userUpdateInfoSchema)
|
||||
.handler(async ({ data }) => {
|
||||
try {
|
||||
const headers = getRequestHeaders();
|
||||
const result = await auth.api.adminUpdateUser({
|
||||
body: {
|
||||
userId: data.id, // required
|
||||
data: { name: data.name }, // required
|
||||
},
|
||||
// This endpoint requires session cookies.
|
||||
headers,
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
const { message, code } = parseError(error);
|
||||
throw { message, code };
|
||||
}
|
||||
});
|
||||
|
||||
export const setUserRole = createServerFn({ method: 'POST' })
|
||||
.middleware([authMiddleware])
|
||||
.inputValidator(userUpdateRoleSchema)
|
||||
.handler(async ({ data }) => {
|
||||
try {
|
||||
const headers = getRequestHeaders();
|
||||
const result = await auth.api.setRole({
|
||||
body: {
|
||||
userId: data.id,
|
||||
role: data.role, // required
|
||||
},
|
||||
// This endpoint requires session cookies.
|
||||
headers,
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
const { message, code } = parseError(error);
|
||||
throw { message, code };
|
||||
}
|
||||
});
|
||||
|
||||
export const banUser = createServerFn({ method: 'POST' })
|
||||
.middleware([authMiddleware])
|
||||
.inputValidator(userBanSchema)
|
||||
.handler(async ({ data }) => {
|
||||
try {
|
||||
const headers = getRequestHeaders();
|
||||
const result = await auth.api.banUser({
|
||||
body: JSON.parse(
|
||||
JSON.stringify({
|
||||
userId: data.id, // required
|
||||
banReason: data.banReason,
|
||||
banExpiresIn:
|
||||
data.banExp === 99999 ? undefined : 60 * 60 * 24 * data.banExp,
|
||||
}),
|
||||
),
|
||||
// This endpoint requires session cookies.
|
||||
headers,
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
const { message, code } = parseError(error);
|
||||
throw { message, code };
|
||||
}
|
||||
});
|
||||
45
src/service/user.schema.ts
Normal file
45
src/service/user.schema.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { m } from '@/paraglide/messages';
|
||||
import z from 'zod';
|
||||
|
||||
export const userListSchema = z.object({
|
||||
page: z.coerce.number().min(1).default(1),
|
||||
limit: z.coerce.number().min(10).max(100).default(10),
|
||||
keyword: z.string().optional(),
|
||||
});
|
||||
|
||||
export const userSetPasswordSchema = z.object({
|
||||
id: z.string().nonempty(m.users_page_message_user_not_found()),
|
||||
password: z
|
||||
.string()
|
||||
.min(5, m.users_page_message_user_min())
|
||||
.regex(/[A-Z]/, m.users_page_message_contain_uppercase())
|
||||
.regex(/[a-z]/, m.users_page_message_contain_lowercase())
|
||||
.regex(/[0-9]/, m.users_page_message_contain_number())
|
||||
.regex(/[^a-zA-Z0-9]/, m.users_page_message_contain_special()),
|
||||
});
|
||||
|
||||
export const userUpdateInfoSchema = z.object({
|
||||
id: z.string().nonempty(m.users_page_message_user_not_found()),
|
||||
name: z.string().nonempty(
|
||||
m.common_is_required({
|
||||
field: m.profile_form_name(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export const RoleEnum = z.enum(['admin', 'user']);
|
||||
|
||||
export const userUpdateRoleSchema = z.object({
|
||||
id: z.string().nonempty(m.users_page_message_user_not_found()),
|
||||
role: RoleEnum,
|
||||
});
|
||||
|
||||
export const userBanSchema = z.object({
|
||||
id: z.string().nonempty(m.users_page_message_user_not_found()),
|
||||
banReason: z.string().nonempty(
|
||||
m.common_is_required({
|
||||
field: m.users_page_ui_form_ban_reason(),
|
||||
}),
|
||||
),
|
||||
banExp: z.number().int().min(1, m.users_page_message_select_min_one_day()),
|
||||
});
|
||||
4
src/types/common.d.ts
vendored
Normal file
4
src/types/common.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface ReturnError extends Error {
|
||||
message: string;
|
||||
code: string;
|
||||
}
|
||||
15
src/types/db.d.ts
vendored
15
src/types/db.d.ts
vendored
@@ -1,7 +1,14 @@
|
||||
import { Audit, User } from '@prisma/client';
|
||||
import { Prisma } from '@/generated/prisma/client';
|
||||
|
||||
declare global {
|
||||
type AuditLog = Audit & {
|
||||
user: User;
|
||||
};
|
||||
type AuditWithUser = Prisma.AuditGetPayload<{
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true;
|
||||
name: true;
|
||||
};
|
||||
};
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
@@ -25,3 +25,19 @@ export function extractDiffObjects<T extends AnyRecord>(
|
||||
[{}, {}] as [Partial<T>, Partial<T>],
|
||||
);
|
||||
}
|
||||
|
||||
export function parseError(error: unknown) {
|
||||
if (typeof error === 'object' && error !== null && 'body' in error) {
|
||||
const e = error as any;
|
||||
return {
|
||||
message: e.body?.message ?? 'Unknown error',
|
||||
code: e.body?.code,
|
||||
};
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return { message: error.message };
|
||||
}
|
||||
|
||||
return { message: String(error) };
|
||||
}
|
||||
Reference in New Issue
Block a user