feature/houses #11
@@ -20,18 +20,33 @@ const Pagination = ({
|
|||||||
const getPageNumbers = () => {
|
const getPageNumbers = () => {
|
||||||
const pages: (number | string)[] = [];
|
const pages: (number | string)[] = [];
|
||||||
|
|
||||||
if (totalPages <= 5) {
|
if (totalPages <= 6) {
|
||||||
// Hiển thị tất cả nếu trang ít
|
// Hiển thị tất cả nếu trang ít
|
||||||
for (let i = 1; i <= totalPages; i++) {
|
for (let i = 1; i <= totalPages; i++) {
|
||||||
pages.push(i);
|
pages.push(i);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (currentPage <= 3) {
|
if (currentPage <= 3) {
|
||||||
pages.push(1, 2, 3, 'dot', totalPages);
|
pages.push(1, 2, 3, 4, 'dot', totalPages);
|
||||||
} else if (currentPage >= totalPages - 2) {
|
} else if (currentPage >= totalPages - 2) {
|
||||||
pages.push(1, 'dot', totalPages - 2, totalPages - 1, totalPages);
|
pages.push(
|
||||||
|
1,
|
||||||
|
'dot',
|
||||||
|
totalPages - 3,
|
||||||
|
totalPages - 2,
|
||||||
|
totalPages - 1,
|
||||||
|
totalPages,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
pages.push(1, 'dot', currentPage, 'dot', totalPages);
|
pages.push(
|
||||||
|
1,
|
||||||
|
'dot',
|
||||||
|
currentPage - 1,
|
||||||
|
currentPage,
|
||||||
|
currentPage + 1,
|
||||||
|
'dot',
|
||||||
|
totalPages,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,6 +63,7 @@ const Pagination = ({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
disabled={currentPage === 1}
|
disabled={currentPage === 1}
|
||||||
|
onClick={() => onPageChange(Number(currentPage - 1))}
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
>
|
>
|
||||||
<CaretLeftIcon />
|
<CaretLeftIcon />
|
||||||
@@ -85,6 +101,7 @@ const Pagination = ({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
disabled={currentPage === totalPages}
|
disabled={currentPage === totalPages}
|
||||||
|
onClick={() => onPageChange(Number(currentPage + 1))}
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
>
|
>
|
||||||
<CaretRightIcon />
|
<CaretRightIcon />
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ClientSession, useSession } from '@lib/auth-client';
|
import { sessionQueries } from '@/service/queries';
|
||||||
import { BetterFetchError } from 'better-auth/client';
|
import { ClientSession } from '@lib/auth-client';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { createContext, useContext, useMemo } from 'react';
|
import { createContext, useContext, useMemo } from 'react';
|
||||||
|
|
||||||
export type UserContext = {
|
export type UserContext = {
|
||||||
@@ -7,12 +8,13 @@ export type UserContext = {
|
|||||||
isAuth: boolean;
|
isAuth: boolean;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
error: BetterFetchError | null;
|
error: Error | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const AuthContext = createContext<UserContext | null>(null);
|
const AuthContext = createContext<UserContext | null>(null);
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
const { data: session, isPending, error } = useSession();
|
const { data: session, isPending, error } = useQuery(sessionQueries.user());
|
||||||
|
|
||||||
const contextSession: UserContext = useMemo(
|
const contextSession: UserContext = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useAppForm } from '@hooks/use-app-form';
|
import { useAppForm } from '@hooks/use-app-form';
|
||||||
import { authClient } from '@lib/auth-client';
|
|
||||||
import { m } from '@paraglide/messages';
|
import { m } from '@paraglide/messages';
|
||||||
import { KeyIcon } from '@phosphor-icons/react';
|
import { KeyIcon } from '@phosphor-icons/react';
|
||||||
import { ChangePassword, ChangePasswordFormSchema } from '@service/user.schema';
|
import { changePassword } from '@service/profile.api';
|
||||||
|
import { ChangePassword, changePasswordFormSchema } from '@service/user.schema';
|
||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@ui/card';
|
||||||
import { Field, FieldGroup } from '@ui/field';
|
import { Field, FieldGroup } from '@ui/field';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@@ -14,37 +15,33 @@ const defaultValues: ChangePassword = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ChangePasswordForm = () => {
|
const ChangePasswordForm = () => {
|
||||||
const form = useAppForm({
|
const { mutate: changePasswordMutation, isPending } = useMutation({
|
||||||
defaultValues,
|
mutationFn: changePassword,
|
||||||
validators: {
|
|
||||||
onSubmit: ChangePasswordFormSchema,
|
|
||||||
onChange: ChangePasswordFormSchema,
|
|
||||||
},
|
|
||||||
onSubmit: async ({ value }) => {
|
|
||||||
await authClient.changePassword(
|
|
||||||
{
|
|
||||||
newPassword: value.newPassword,
|
|
||||||
currentPassword: value.currentPassword,
|
|
||||||
revokeOtherSessions: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
form.reset();
|
form.reset();
|
||||||
toast.success(
|
toast.success(m.change_password_messages_change_password_success(), {
|
||||||
m.change_password_messages_change_password_success(),
|
|
||||||
{
|
|
||||||
richColors: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
onError: (ctx) => {
|
|
||||||
console.error(ctx.error.code);
|
|
||||||
toast.error(m.backend_message({ code: ctx.error.code }), {
|
|
||||||
richColors: true,
|
richColors: true,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
onError: (error: ReturnError) => {
|
||||||
|
console.error(error);
|
||||||
|
const code = error.code as Parameters<
|
||||||
|
typeof m.backend_message
|
||||||
|
>[0]['code'];
|
||||||
|
toast.error(m.backend_message({ code }), {
|
||||||
|
richColors: true,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
);
|
});
|
||||||
|
|
||||||
|
const form = useAppForm({
|
||||||
|
defaultValues,
|
||||||
|
validators: {
|
||||||
|
onSubmit: changePasswordFormSchema,
|
||||||
|
onChange: changePasswordFormSchema,
|
||||||
|
},
|
||||||
|
onSubmit: async ({ value }) => {
|
||||||
|
changePasswordMutation({ data: value });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -70,6 +67,7 @@ const ChangePasswordForm = () => {
|
|||||||
{(field) => (
|
{(field) => (
|
||||||
<field.TextField
|
<field.TextField
|
||||||
label={m.change_password_form_current_password()}
|
label={m.change_password_form_current_password()}
|
||||||
|
type="password"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</form.AppField>
|
</form.AppField>
|
||||||
@@ -91,7 +89,10 @@ const ChangePasswordForm = () => {
|
|||||||
</form.AppField>
|
</form.AppField>
|
||||||
<Field>
|
<Field>
|
||||||
<form.AppForm>
|
<form.AppForm>
|
||||||
<form.SubscribeButton label={m.ui_change_password_btn()} />
|
<form.SubscribeButton
|
||||||
|
label={m.ui_change_password_btn()}
|
||||||
|
disabled={isPending}
|
||||||
|
/>
|
||||||
</form.AppForm>
|
</form.AppForm>
|
||||||
</Field>
|
</Field>
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { updateProfile } from '@/service/profile.api';
|
||||||
import { useAuth } from '@components/auth/auth-provider';
|
import { useAuth } from '@components/auth/auth-provider';
|
||||||
import AvatarUser from '@components/avatar/avatar-user';
|
import AvatarUser from '@components/avatar/avatar-user';
|
||||||
import RoleBadge from '@components/avatar/role-badge';
|
import RoleBadge from '@components/avatar/role-badge';
|
||||||
import { useAppForm } from '@hooks/use-app-form';
|
import { useAppForm } from '@hooks/use-app-form';
|
||||||
import { authClient } from '@lib/auth-client';
|
|
||||||
import { m } from '@paraglide/messages';
|
import { m } from '@paraglide/messages';
|
||||||
import { UserCircleIcon } from '@phosphor-icons/react';
|
import { UserCircleIcon } from '@phosphor-icons/react';
|
||||||
import { uploadProfileImage } from '@service/profile.api';
|
|
||||||
import { ProfileInput, profileUpdateSchema } from '@service/profile.schema';
|
import { ProfileInput, profileUpdateSchema } from '@service/profile.schema';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@ui/card';
|
||||||
import { Field, FieldGroup, FieldLabel } from '@ui/field';
|
import { Field, FieldGroup, FieldLabel } from '@ui/field';
|
||||||
import { Input } from '@ui/input';
|
import { Input } from '@ui/input';
|
||||||
@@ -24,34 +24,8 @@ const ProfileForm = () => {
|
|||||||
const { data: session, isPending } = useAuth();
|
const { data: session, isPending } = useAuth();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const form = useAppForm({
|
const { mutate: updateProfileMutation, isPending: isRunning } = useMutation({
|
||||||
defaultValues: {
|
mutationFn: updateProfile,
|
||||||
...defaultValues,
|
|
||||||
name: session?.user?.name || '',
|
|
||||||
},
|
|
||||||
validators: {
|
|
||||||
onSubmit: profileUpdateSchema,
|
|
||||||
onChange: profileUpdateSchema,
|
|
||||||
},
|
|
||||||
onSubmit: async ({ value }) => {
|
|
||||||
try {
|
|
||||||
let imageKey;
|
|
||||||
if (value.image) {
|
|
||||||
// upload image
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.set('file', value.image);
|
|
||||||
const { imageKey: uploadedKey } = await uploadProfileImage({
|
|
||||||
data: formData,
|
|
||||||
});
|
|
||||||
imageKey = uploadedKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
await authClient.updateUser(
|
|
||||||
{
|
|
||||||
name: value.name,
|
|
||||||
image: imageKey,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
form.reset();
|
form.reset();
|
||||||
if (fileInputRef.current) {
|
if (fileInputRef.current) {
|
||||||
@@ -64,25 +38,40 @@ const ProfileForm = () => {
|
|||||||
richColors: true,
|
richColors: true,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: (ctx) => {
|
onError: (error: ReturnError) => {
|
||||||
console.error(ctx.error.code);
|
console.error(error);
|
||||||
toast.error(m.backend_message({ code: ctx.error.code }), {
|
const code = error.code as Parameters<
|
||||||
|
typeof m.backend_message
|
||||||
|
>[0]['code'];
|
||||||
|
toast.error(m.backend_message({ code }), {
|
||||||
richColors: true,
|
richColors: true,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
},
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('update load file', error);
|
|
||||||
toast.error(JSON.stringify(error), {
|
|
||||||
richColors: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isPending) return null;
|
const form = useAppForm({
|
||||||
if (!session?.user?.name) return null;
|
defaultValues: {
|
||||||
|
...defaultValues,
|
||||||
|
name: session?.user?.name || '',
|
||||||
|
},
|
||||||
|
validators: {
|
||||||
|
onSubmit: profileUpdateSchema,
|
||||||
|
onChange: profileUpdateSchema,
|
||||||
|
},
|
||||||
|
onSubmit: async ({ value }) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.set('name', value.name);
|
||||||
|
if (value.image) {
|
||||||
|
formData.set('file', value.image);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateProfileMutation({ data: formData });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isPending || !session?.user?.name) {
|
||||||
|
return <Skeleton className="h-100 col-span-1" />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="@container/card col-span-1">
|
<Card className="@container/card col-span-1">
|
||||||
@@ -136,7 +125,10 @@ const ProfileForm = () => {
|
|||||||
</Field>
|
</Field>
|
||||||
<Field>
|
<Field>
|
||||||
<form.AppForm>
|
<form.AppForm>
|
||||||
<form.SubscribeButton label={m.ui_update_btn()} />
|
<form.SubscribeButton
|
||||||
|
label={m.ui_update_btn()}
|
||||||
|
disabled={isRunning}
|
||||||
|
/>
|
||||||
</form.AppForm>
|
</form.AppForm>
|
||||||
</Field>
|
</Field>
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { slugify } from '@utils/helper';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
type EditHouseFormProps = {
|
type EditHouseFormProps = {
|
||||||
data: OrganizationWithMembers;
|
data: HouseWithMembers;
|
||||||
onSubmit: (open: boolean) => void;
|
onSubmit: (open: boolean) => void;
|
||||||
mutateKey: string;
|
mutateKey: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ import DeleteUserHouseAction from './delete-user-house-dialog';
|
|||||||
import EditHouseAction from './edit-house-dialog';
|
import EditHouseAction from './edit-house-dialog';
|
||||||
|
|
||||||
type CurrentUserActionGroupProps = {
|
type CurrentUserActionGroupProps = {
|
||||||
|
oneHouse: boolean;
|
||||||
activeHouse: ReturnType<typeof authClient.useActiveOrganization>['data'];
|
activeHouse: ReturnType<typeof authClient.useActiveOrganization>['data'];
|
||||||
};
|
};
|
||||||
|
|
||||||
const CurrentUserActionGroup = ({
|
const CurrentUserActionGroup = ({
|
||||||
|
oneHouse,
|
||||||
activeHouse,
|
activeHouse,
|
||||||
}: CurrentUserActionGroupProps) => {
|
}: CurrentUserActionGroupProps) => {
|
||||||
return (
|
return (
|
||||||
@@ -22,10 +24,7 @@ const CurrentUserActionGroup = ({
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-row gap-2">
|
<CardContent className="flex flex-row gap-2">
|
||||||
<EditHouseAction
|
<EditHouseAction data={activeHouse as HouseWithMembers} isPersonal>
|
||||||
data={activeHouse as OrganizationWithMembers}
|
|
||||||
isPersonal
|
|
||||||
>
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="icon-lg"
|
size="icon-lg"
|
||||||
@@ -35,7 +34,7 @@ const CurrentUserActionGroup = ({
|
|||||||
<span className="sr-only">{m.ui_edit_house_btn()}</span>
|
<span className="sr-only">{m.ui_edit_house_btn()}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</EditHouseAction>
|
</EditHouseAction>
|
||||||
<DeleteUserHouseAction activeHouse={activeHouse} />
|
{!oneHouse && <DeleteUserHouseAction activeHouse={activeHouse} />}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ import { authClient } from '@lib/auth-client';
|
|||||||
import { cn } from '@lib/utils';
|
import { cn } from '@lib/utils';
|
||||||
import { m } from '@paraglide/messages';
|
import { m } from '@paraglide/messages';
|
||||||
import { CheckIcon, WarehouseIcon } from '@phosphor-icons/react';
|
import { CheckIcon, WarehouseIcon } from '@phosphor-icons/react';
|
||||||
import { housesQueries } from '@service/queries';
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
import { Button } from '@ui/button';
|
import { Button } from '@ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@ui/card';
|
||||||
import parse from 'html-react-parser';
|
import parse from 'html-react-parser';
|
||||||
@@ -20,11 +18,15 @@ import { Skeleton } from '../ui/skeleton';
|
|||||||
import CreateNewHouse from './create-house-dialog';
|
import CreateNewHouse from './create-house-dialog';
|
||||||
|
|
||||||
type CurrentUserHouseListProps = {
|
type CurrentUserHouseListProps = {
|
||||||
|
houses: HouseWithMembersCount[];
|
||||||
activeHouse: ReturnType<typeof authClient.useActiveOrganization>['data'];
|
activeHouse: ReturnType<typeof authClient.useActiveOrganization>['data'];
|
||||||
};
|
};
|
||||||
|
|
||||||
const CurrentUserHouseList = ({ activeHouse }: CurrentUserHouseListProps) => {
|
const CurrentUserHouseList = ({
|
||||||
const { data: houses } = useQuery(housesQueries.currentUser());
|
activeHouse,
|
||||||
|
houses,
|
||||||
|
}: CurrentUserHouseListProps) => {
|
||||||
|
// const { data: houses } = useQuery(housesQueries.currentUser());
|
||||||
|
|
||||||
const activeHouseAction = async ({
|
const activeHouseAction = async ({
|
||||||
id,
|
id,
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ import RoleBadge from '../avatar/role-badge';
|
|||||||
import { Spinner } from '../ui/spinner';
|
import { Spinner } from '../ui/spinner';
|
||||||
|
|
||||||
type DeleteHouseProps = {
|
type DeleteHouseProps = {
|
||||||
data: OrganizationWithMembers;
|
data: HouseWithMembers;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DeleteHouseAction = ({ data }: DeleteHouseProps) => {
|
const DeleteHouseAction = ({ data }: DeleteHouseProps) => {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@ui/tooltip';
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
type EditHouseProps = {
|
type EditHouseProps = {
|
||||||
data: OrganizationWithMembers;
|
data: HouseWithMembers;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
isPersonal?: boolean;
|
isPersonal?: boolean;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import DeleteHouseAction from './delete-house-dialog';
|
|||||||
import EditHouseAction from './edit-house-dialog';
|
import EditHouseAction from './edit-house-dialog';
|
||||||
import ViewDetailHouse from './view-house-detail-dialog';
|
import ViewDetailHouse from './view-house-detail-dialog';
|
||||||
|
|
||||||
export const houseColumns: ColumnDef<OrganizationWithMembers>[] = [
|
export const houseColumns: ColumnDef<HouseWithMembers>[] = [
|
||||||
{
|
{
|
||||||
accessorKey: 'name',
|
accessorKey: 'name',
|
||||||
header: m.houses_page_ui_table_header_name(),
|
header: m.houses_page_ui_table_header_name(),
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import { formatters } from '@utils/formatters';
|
|||||||
import RoleBadge from '../avatar/role-badge';
|
import RoleBadge from '../avatar/role-badge';
|
||||||
|
|
||||||
type ViewDetailProps = {
|
type ViewDetailProps = {
|
||||||
data: OrganizationWithMembers;
|
data: HouseWithMembers;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ViewDetailHouse = ({ data }: ViewDetailProps) => {
|
const ViewDetailHouse = ({ data }: ViewDetailProps) => {
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
import { sessionQueries } from '@service/queries';
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
|
|
||||||
export function useSessionQuery() {
|
|
||||||
return useQuery(sessionQueries.user());
|
|
||||||
}
|
|
||||||
@@ -77,43 +77,6 @@ export const auth = betterAuth({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
update: {
|
|
||||||
before: async (user, ctx) => {
|
|
||||||
if (ctx?.context.session && ctx?.path === '/update-user') {
|
|
||||||
const newUser = JSON.parse(JSON.stringify(user));
|
|
||||||
const keys = Object.keys(newUser);
|
|
||||||
const oldUser = Object.fromEntries(
|
|
||||||
Object.entries(ctx?.context.session?.user).filter(([key]) =>
|
|
||||||
keys.includes(key),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await createAuditLog({
|
|
||||||
action: LOG_ACTION.UPDATE,
|
|
||||||
tableName: DB_TABLE.USER,
|
|
||||||
recordId: ctx?.context.session?.user.id,
|
|
||||||
oldValue: JSON.stringify(oldUser),
|
|
||||||
newValue: JSON.stringify(newUser),
|
|
||||||
userId: ctx?.context.session?.user.id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
account: {
|
|
||||||
update: {
|
|
||||||
after: async (account, context) => {
|
|
||||||
if (context?.path === '/change-password') {
|
|
||||||
await createAuditLog({
|
|
||||||
action: LOG_ACTION.CHANGE_PASSWORD,
|
|
||||||
tableName: DB_TABLE.ACCOUNT,
|
|
||||||
recordId: account.id,
|
|
||||||
oldValue: 'Change Password',
|
|
||||||
newValue: 'Change Password',
|
|
||||||
userId: account.userId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
create: {
|
create: {
|
||||||
|
|||||||
1
src/lib/errors/index.ts
Normal file
1
src/lib/errors/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './parse-error';
|
||||||
77
src/lib/errors/parse-error.ts
Normal file
77
src/lib/errors/parse-error.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { Prisma } from '@/generated/prisma/client';
|
||||||
|
|
||||||
|
export type ErrorBody = {
|
||||||
|
message?: unknown;
|
||||||
|
code?: unknown;
|
||||||
|
status?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
function hasErrorBody(error: unknown): error is { body: ErrorBody } {
|
||||||
|
return (
|
||||||
|
typeof error === 'object' &&
|
||||||
|
error !== null &&
|
||||||
|
'body' in error &&
|
||||||
|
typeof (error as any).body === 'object'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseError(error: unknown): {
|
||||||
|
message: string;
|
||||||
|
code?: string;
|
||||||
|
status?: number;
|
||||||
|
} {
|
||||||
|
// Recognize AppError even if it's a plain object from network
|
||||||
|
if (
|
||||||
|
typeof error === 'object' &&
|
||||||
|
error !== null &&
|
||||||
|
'name' in error &&
|
||||||
|
(error as any).name === 'AppError' &&
|
||||||
|
'code' in error &&
|
||||||
|
'message' in error &&
|
||||||
|
'status' in error
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
message: (error as any).message,
|
||||||
|
code: (error as any).code,
|
||||||
|
status: (error as any).status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// better-auth / fetch error (có body)
|
||||||
|
if (hasErrorBody(error)) {
|
||||||
|
return {
|
||||||
|
message:
|
||||||
|
typeof error.body.message === 'string'
|
||||||
|
? error.body.message
|
||||||
|
: 'Unknown error',
|
||||||
|
code: typeof error.body.code === 'string' ? error.body.code : undefined,
|
||||||
|
status:
|
||||||
|
typeof error.body.status === 'number' ? error.body.status : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prisma (giữ nguyên code như "P2002")
|
||||||
|
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||||
|
return {
|
||||||
|
message: error.message,
|
||||||
|
code: error.code,
|
||||||
|
status: 400,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error thường
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return {
|
||||||
|
message: error.message,
|
||||||
|
code: 'NORMAL_ERROR',
|
||||||
|
status: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback
|
||||||
|
return {
|
||||||
|
message: String(error),
|
||||||
|
code: 'NORMAL_ERROR',
|
||||||
|
status: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -21,9 +21,12 @@ function RouteComponent() {
|
|||||||
return (
|
return (
|
||||||
<div className="@container/main p-4">
|
<div className="@container/main 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">
|
<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">
|
||||||
<CurrentUserHouseList activeHouse={activeHouse} />
|
<CurrentUserHouseList activeHouse={activeHouse} houses={houses} />
|
||||||
<CurrentUserMemberList activeHouse={activeHouse} />
|
<CurrentUserMemberList activeHouse={activeHouse} />
|
||||||
<CurrentUserActionGroup activeHouse={activeHouse} />
|
<CurrentUserActionGroup
|
||||||
|
activeHouse={activeHouse}
|
||||||
|
oneHouse={houses.length === 1}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { prisma } from '@/db';
|
import { prisma } from '@/db';
|
||||||
import { AuditWhereInput } from '@/generated/prisma/models';
|
import { AuditWhereInput } from '@/generated/prisma/models';
|
||||||
|
import { parseError } from '@lib/errors';
|
||||||
import { authMiddleware } from '@lib/middleware';
|
import { authMiddleware } from '@lib/middleware';
|
||||||
import { createServerFn } from '@tanstack/react-start';
|
import { createServerFn } from '@tanstack/react-start';
|
||||||
import { parseError } from '@utils/helper';
|
|
||||||
import { auditListSchema } from './audit.schema';
|
import { auditListSchema } from './audit.schema';
|
||||||
|
|
||||||
export const getAllAudit = createServerFn({ method: 'GET' })
|
export const getAllAudit = createServerFn({ method: 'GET' })
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { AppError } from '@/lib/errors';
|
||||||
import fs, { writeFile } from 'fs/promises';
|
import fs, { writeFile } from 'fs/promises';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
@@ -19,8 +20,10 @@ export async function saveFile(key: string, file: Buffer | File) {
|
|||||||
return key;
|
return key;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error saving file: ${key}`, error);
|
console.error(`Error saving file: ${key}`, error);
|
||||||
throw new Error(
|
throw new AppError(
|
||||||
|
'FILE_SAVE_ERROR',
|
||||||
`Failed to save file: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
`Failed to save file: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||||
|
500,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,10 +2,10 @@ import { prisma } from '@/db';
|
|||||||
import { OrganizationWhereInput } from '@/generated/prisma/models';
|
import { OrganizationWhereInput } from '@/generated/prisma/models';
|
||||||
import { DB_TABLE, LOG_ACTION } from '@/types/enum';
|
import { DB_TABLE, LOG_ACTION } from '@/types/enum';
|
||||||
import { auth } from '@lib/auth';
|
import { auth } from '@lib/auth';
|
||||||
|
import { parseError } from '@lib/errors';
|
||||||
import { authMiddleware } from '@lib/middleware';
|
import { authMiddleware } from '@lib/middleware';
|
||||||
import { createServerFn } from '@tanstack/react-start';
|
import { createServerFn } from '@tanstack/react-start';
|
||||||
import { getRequestHeaders } from '@tanstack/react-start/server';
|
import { getRequestHeaders } from '@tanstack/react-start/server';
|
||||||
import { parseError } from '@utils/helper';
|
|
||||||
import {
|
import {
|
||||||
baseHouse,
|
baseHouse,
|
||||||
houseCreateBESchema,
|
houseCreateBESchema,
|
||||||
@@ -33,8 +33,7 @@ export const getAllHouse = createServerFn({ method: 'GET' })
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const [list, total]: [OrganizationWithMembers[], number] =
|
const [list, total]: [HouseWithMembers[], number] = await Promise.all([
|
||||||
await Promise.all([
|
|
||||||
await prisma.organization.findMany({
|
await prisma.organization.findMany({
|
||||||
where,
|
where,
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
|
|||||||
@@ -1,17 +1,92 @@
|
|||||||
|
import { prisma } from '@/db';
|
||||||
|
import { auth } from '@/lib/auth';
|
||||||
|
import { parseError } from '@/lib/errors';
|
||||||
|
import { DB_TABLE, LOG_ACTION } from '@/types/enum';
|
||||||
import { authMiddleware } from '@lib/middleware';
|
import { authMiddleware } from '@lib/middleware';
|
||||||
import { createServerFn } from '@tanstack/react-start';
|
import { createServerFn } from '@tanstack/react-start';
|
||||||
import { saveFile } from '@utils/disk-storage';
|
import { getRequestHeaders } from '@tanstack/react-start/server';
|
||||||
import z from 'zod';
|
import z from 'zod';
|
||||||
|
import { saveFile } from './disk-storage';
|
||||||
|
import { createAuditLog } from './repository';
|
||||||
|
import { changePasswordBESchema } from './user.schema';
|
||||||
|
|
||||||
export const uploadProfileImage = createServerFn({ method: 'POST' })
|
export const updateProfile = createServerFn({ method: 'POST' })
|
||||||
.middleware([authMiddleware])
|
.middleware([authMiddleware])
|
||||||
.inputValidator(z.instanceof(FormData))
|
.inputValidator(z.instanceof(FormData))
|
||||||
.handler(async ({ data: formData }) => {
|
.handler(async ({ data: formData, context: { user } }) => {
|
||||||
const uuid = crypto.randomUUID();
|
try {
|
||||||
|
let imageKey;
|
||||||
const file = formData.get('file') as File;
|
const file = formData.get('file') as File;
|
||||||
|
if (file) {
|
||||||
|
const uuid = crypto.randomUUID();
|
||||||
if (!(file instanceof File)) throw new Error('File not found');
|
if (!(file instanceof File)) throw new Error('File not found');
|
||||||
const imageKey = `${uuid}.${file.type.split('/')[1]}`;
|
|
||||||
const buffer = Buffer.from(await file.arrayBuffer());
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
await saveFile(imageKey, buffer);
|
imageKey = await saveFile(`${uuid}.${file.type.split('/')[1]}`, buffer);
|
||||||
return { imageKey };
|
}
|
||||||
|
|
||||||
|
const getOldUser = await prisma.user.findUnique({
|
||||||
|
where: { id: user.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
const name = formData.get('name') as string;
|
||||||
|
|
||||||
|
const newUser = JSON.parse(JSON.stringify({ name, image: imageKey }));
|
||||||
|
const keys = Object.keys(newUser);
|
||||||
|
const oldUser = Object.fromEntries(
|
||||||
|
Object.entries(getOldUser || {}).filter(([key]) => keys.includes(key)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const headers = getRequestHeaders();
|
||||||
|
const result = await auth.api.updateUser({
|
||||||
|
body: newUser,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
await createAuditLog({
|
||||||
|
action: LOG_ACTION.UPDATE,
|
||||||
|
tableName: DB_TABLE.USER,
|
||||||
|
recordId: user.id,
|
||||||
|
oldValue: JSON.stringify(oldUser),
|
||||||
|
newValue: JSON.stringify(newUser),
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
const { message, code } = parseError(error);
|
||||||
|
throw { message, code };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const changePassword = createServerFn({ method: 'POST' })
|
||||||
|
.middleware([authMiddleware])
|
||||||
|
.inputValidator(changePasswordBESchema)
|
||||||
|
.handler(async ({ data, context: { user } }) => {
|
||||||
|
try {
|
||||||
|
const headers = getRequestHeaders();
|
||||||
|
const result = await auth.api.changePassword({
|
||||||
|
body: {
|
||||||
|
newPassword: data.newPassword, // required
|
||||||
|
currentPassword: data.currentPassword, // required
|
||||||
|
revokeOtherSessions: true,
|
||||||
|
},
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
await createAuditLog({
|
||||||
|
action: LOG_ACTION.CHANGE_PASSWORD,
|
||||||
|
tableName: DB_TABLE.ACCOUNT,
|
||||||
|
recordId: user.id,
|
||||||
|
oldValue: 'Change Password',
|
||||||
|
newValue: 'Change Password',
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
// console.error(error);
|
||||||
|
const { message, code } = parseError(error);
|
||||||
|
throw { message, code };
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { prisma } from '@/db';
|
import { prisma } from '@/db';
|
||||||
import { Audit, Setting } from '@/generated/prisma/client';
|
import { Audit, Setting } from '@/generated/prisma/client';
|
||||||
import { parseError } from '@utils/helper';
|
|
||||||
|
|
||||||
type AdminSettingValue = Pick<Setting, 'id' | 'key' | 'value'>;
|
type AdminSettingValue = Pick<Setting, 'id' | 'key' | 'value'>;
|
||||||
|
|
||||||
@@ -42,17 +41,11 @@ export async function getAllAdminSettings(valueOnly = false) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const createAuditLog = async (data: Omit<Audit, 'id' | 'createdAt'>) => {
|
export const createAuditLog = async (data: Omit<Audit, 'id' | 'createdAt'>) => {
|
||||||
try {
|
|
||||||
await prisma.audit.create({
|
await prisma.audit.create({
|
||||||
data: {
|
data: {
|
||||||
...data,
|
...data,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
const { message, code } = parseError(error);
|
|
||||||
throw { message, code };
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getInitialOrganization = async (userId: string) => {
|
export const getInitialOrganization = async (userId: string) => {
|
||||||
@@ -65,5 +58,6 @@ export const getInitialOrganization = async (userId: string) => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return organization;
|
return organization;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { prisma } from '@/db';
|
import { prisma } from '@/db';
|
||||||
|
import { parseError } from '@/lib/errors';
|
||||||
import { authMiddleware } from '@lib/middleware';
|
import { authMiddleware } from '@lib/middleware';
|
||||||
import { createServerFn } from '@tanstack/react-start';
|
import { createServerFn } from '@tanstack/react-start';
|
||||||
import { extractDiffObjects, parseError } from '@utils/helper';
|
import { extractDiffObjects } from '@utils/helper';
|
||||||
import { createAuditLog, getAllAdminSettings } from './repository';
|
import { createAuditLog, getAllAdminSettings } from './repository';
|
||||||
import { settingSchema, userSettingSchema } from './setting.schema';
|
import { settingSchema, userSettingSchema } from './setting.schema';
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { prisma } from '@/db';
|
import { prisma } from '@/db';
|
||||||
import { DB_TABLE, LOG_ACTION } from '@/types/enum';
|
import { DB_TABLE, LOG_ACTION } from '@/types/enum';
|
||||||
import { auth } from '@lib/auth';
|
import { auth } from '@lib/auth';
|
||||||
|
import { parseError } from '@lib/errors';
|
||||||
import { authMiddleware } from '@lib/middleware';
|
import { authMiddleware } from '@lib/middleware';
|
||||||
import { createServerFn } from '@tanstack/react-start';
|
import { createServerFn } from '@tanstack/react-start';
|
||||||
import { getRequestHeaders } from '@tanstack/react-start/server';
|
import { getRequestHeaders } from '@tanstack/react-start/server';
|
||||||
import { parseError } from '@utils/helper';
|
|
||||||
import { createAuditLog } from './repository';
|
import { createAuditLog } from './repository';
|
||||||
import {
|
import {
|
||||||
baseUser,
|
baseUser,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export const baseUser = z.object({
|
|||||||
id: z.string().nonempty(m.users_page_message_user_not_found()),
|
id: z.string().nonempty(m.users_page_message_user_not_found()),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ChangePasswordFormSchema = z
|
export const changePasswordFormSchema = z
|
||||||
.object({
|
.object({
|
||||||
currentPassword: z.string().nonempty(
|
currentPassword: z.string().nonempty(
|
||||||
m.common_is_required({
|
m.common_is_required({
|
||||||
@@ -33,7 +33,20 @@ export const ChangePasswordFormSchema = z
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ChangePassword = z.infer<typeof ChangePasswordFormSchema>;
|
export const changePasswordBESchema = z.object({
|
||||||
|
currentPassword: z.string().nonempty(
|
||||||
|
m.common_is_required({
|
||||||
|
field: m.change_password_form_current_password(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
newPassword: z.string().nonempty(
|
||||||
|
m.common_is_required({
|
||||||
|
field: m.change_password_form_new_password(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ChangePassword = z.infer<typeof changePasswordFormSchema>;
|
||||||
|
|
||||||
export const userListSchema = z.object({
|
export const userListSchema = z.object({
|
||||||
page: z.coerce.number().min(1).default(1),
|
page: z.coerce.number().min(1).default(1),
|
||||||
|
|||||||
10
src/types/db.d.ts
vendored
10
src/types/db.d.ts
vendored
@@ -12,7 +12,7 @@ declare global {
|
|||||||
};
|
};
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
type OrganizationWithMembers = Prisma.OrganizationGetPayload<{
|
type HouseWithMembers = Prisma.OrganizationGetPayload<{
|
||||||
include: {
|
include: {
|
||||||
members: {
|
members: {
|
||||||
select: {
|
select: {
|
||||||
@@ -30,8 +30,14 @@ declare global {
|
|||||||
};
|
};
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
type HouseWithMembersCount = HouseWithMembers & {
|
||||||
|
_count: {
|
||||||
|
members: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
type ReturnError = Error & {
|
type ReturnError = Error & {
|
||||||
message: string;
|
|
||||||
code: string;
|
code: string;
|
||||||
|
message: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,22 +26,6 @@ export function extractDiffObjects<T extends AnyRecord>(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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) };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function slugify(text: string) {
|
export function slugify(text: string) {
|
||||||
return text
|
return text
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
|
|||||||
Reference in New Issue
Block a user