@thinhnguyencth1204/nextcli 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -24
- package/dist/cli.js +168 -107
- package/package.json +5 -3
- package/templates/features/supabase/src/lib/supabase/rich-text-image-sync.ts +28 -0
- package/templates/next-base/.env +16 -0
- package/templates/next-base/.env.development +16 -0
- package/templates/next-base/.env.example +16 -0
- package/templates/next-base/PROJECT_STRUCTURE.md +29 -18
- package/templates/next-base/SETUP.md +62 -10
- package/templates/next-base/bun.lock +59 -414
- package/templates/next-base/messages/vi/auth.json +42 -0
- package/templates/next-base/messages/vi/common.json +34 -0
- package/templates/next-base/messages/vi/example.json +10 -0
- package/templates/next-base/next-env.d.ts +1 -1
- package/templates/next-base/next.config.ts +4 -1
- package/templates/next-base/nextcli.json +12 -4
- package/templates/next-base/package.json +25 -1
- package/templates/next-base/prisma/schema.prisma +84 -0
- package/templates/next-base/prisma.config.ts +16 -0
- package/templates/next-base/src/app/(auth)/.gitkeep +1 -0
- package/templates/next-base/src/app/(auth)/change-password/layout.tsx +21 -0
- package/templates/next-base/src/app/(auth)/change-password/page.tsx +14 -0
- package/templates/next-base/src/app/(auth)/layout.tsx +9 -0
- package/templates/next-base/src/app/(auth)/sign-in/layout.tsx +17 -0
- package/templates/next-base/src/app/(auth)/sign-in/page.tsx +14 -0
- package/templates/next-base/src/app/(dashboard)/account/page.tsx +18 -0
- package/templates/next-base/src/app/(dashboard)/dashboard/page.tsx +17 -0
- package/templates/next-base/src/app/(dashboard)/example/page.tsx +13 -0
- package/templates/next-base/src/app/(dashboard)/layout.tsx +22 -0
- package/templates/next-base/src/app/api/auth/[...all]/route.ts +4 -0
- package/templates/next-base/src/app/api/v1/auth/change-password/route.ts +55 -0
- package/templates/next-base/src/app/api/v1/auth/login/route.ts +70 -0
- package/templates/next-base/src/app/api/v1/auth/logout/route.ts +28 -0
- package/templates/next-base/src/app/api/v1/auth/me/route.ts +24 -0
- package/templates/next-base/src/app/api/v1/auth/refresh/route.ts +32 -0
- package/templates/next-base/src/app/api/v1/example/route.ts +34 -0
- package/templates/next-base/src/app/api/v1/users/[id]/route.ts +104 -0
- package/templates/next-base/src/app/api/v1/users/route.ts +58 -0
- package/templates/next-base/src/app/blog-demo/page.tsx +9 -0
- package/templates/next-base/src/app/globals.css +57 -0
- package/templates/next-base/src/app/layout.tsx +14 -6
- package/templates/next-base/src/app/page.tsx +2 -25
- package/templates/next-base/src/components/layout/private/app-sidebar.tsx +44 -0
- package/templates/next-base/src/components/layout/private/dashboard-layout.tsx +54 -0
- package/templates/next-base/src/components/layout/private/locale-switcher.tsx +45 -0
- package/templates/next-base/src/components/layout/private/nav-sidebar.tsx +55 -0
- package/templates/next-base/src/components/layout/private/nav-user.tsx +99 -0
- package/templates/next-base/src/components/providers/query-provider.tsx +17 -0
- package/templates/next-base/src/components/rich-text/adapters/textarea-field.tsx +50 -0
- package/templates/next-base/src/components/rich-text/client-only.tsx +23 -0
- package/templates/next-base/src/components/rich-text/editor-field.tsx +62 -0
- package/templates/next-base/src/components/rich-text/examples/blog-rich-text-demo.tsx +218 -0
- package/templates/next-base/src/components/rich-text/index.ts +11 -0
- package/templates/next-base/src/components/rich-text/lexical/extension.ts +37 -0
- package/templates/next-base/src/components/rich-text/lexical/nodes/image-node.tsx +187 -0
- package/templates/next-base/src/components/rich-text/lexical/plugins/image-plugin.tsx +40 -0
- package/templates/next-base/src/components/rich-text/lexical/plugins/initial-state-plugin.tsx +26 -0
- package/templates/next-base/src/components/rich-text/lexical/plugins/on-change-plugin.tsx +26 -0
- package/templates/next-base/src/components/rich-text/lexical/plugins/toolbar-plugin.tsx +190 -0
- package/templates/next-base/src/components/rich-text/lexical/rich-text-editor.tsx +121 -0
- package/templates/next-base/src/components/rich-text/lexical/theme.ts +18 -0
- package/templates/next-base/src/components/rich-text/rich-text-renderer.tsx +72 -0
- package/templates/next-base/src/components/rich-text/types.ts +60 -0
- package/templates/next-base/src/components/ui/data-table/data-table-column-header.tsx +23 -0
- package/templates/next-base/src/components/ui/data-table/data-table-filter-list.tsx +3 -0
- package/templates/next-base/src/components/ui/data-table/data-table-pagination.tsx +35 -0
- package/templates/next-base/src/components/ui/data-table/data-table-skeleton.tsx +11 -0
- package/templates/next-base/src/components/ui/data-table/data-table-toolbar.tsx +14 -0
- package/templates/next-base/src/components/ui/data-table/data-table-view-options.tsx +3 -0
- package/templates/next-base/src/components/ui/data-table/data-table.tsx +72 -0
- package/templates/next-base/src/components/ui/sidebar.tsx +215 -0
- package/templates/next-base/src/data/sidebar-modules.ts +11 -0
- package/templates/next-base/src/example/api/use-example.ts +21 -0
- package/templates/next-base/src/example/api/use-mutations.ts +20 -0
- package/templates/next-base/src/example/components/example-table.tsx +51 -0
- package/templates/next-base/src/example/services.ts +9 -0
- package/templates/next-base/src/example/validations.ts +8 -0
- package/templates/next-base/src/features/auth/components/account-panel.tsx +80 -0
- package/templates/next-base/src/features/auth/components/change-password-form.tsx +82 -0
- package/templates/next-base/src/features/auth/components/sign-in-form.tsx +95 -0
- package/templates/next-base/src/features/auth/validations.ts +14 -0
- package/templates/next-base/src/features/users/services.ts +132 -0
- package/templates/next-base/src/features/users/validations.ts +21 -0
- package/templates/next-base/src/hooks/index.ts +1 -1
- package/templates/next-base/src/hooks/table/use-data-table.ts +33 -0
- package/templates/next-base/src/hooks/use-mobile.ts +25 -0
- package/templates/next-base/src/i18n/config.ts +7 -0
- package/templates/next-base/src/i18n/namespaces.ts +5 -0
- package/templates/next-base/src/i18n/request.ts +25 -0
- package/templates/next-base/src/instrumentation.ts +14 -0
- package/templates/next-base/src/lib/api/axios.ts +145 -0
- package/templates/next-base/src/lib/api/response.ts +45 -0
- package/templates/next-base/src/lib/api/token-store.ts +13 -0
- package/templates/next-base/src/lib/auth/bootstrap.ts +95 -0
- package/templates/next-base/src/lib/auth/client.ts +7 -0
- package/templates/next-base/src/lib/auth/cookies.ts +15 -0
- package/templates/next-base/src/lib/auth/index.ts +1 -0
- package/templates/next-base/src/lib/auth/rbac.ts +59 -0
- package/templates/next-base/src/lib/auth/server.ts +21 -0
- package/templates/next-base/src/lib/constants.ts +10 -0
- package/templates/next-base/src/lib/db/prisma.ts +23 -0
- package/templates/next-base/src/lib/prisma.ts +23 -0
- package/templates/next-base/src/lib/rich-text/default-image-removal.ts +10 -0
- package/templates/next-base/src/lib/rich-text/image-urls.ts +41 -0
- package/templates/next-base/src/lib/rich-text/index.ts +12 -0
- package/templates/next-base/src/lib/rich-text/supabase-url.ts +67 -0
- package/templates/next-base/src/lib/rich-text/sync-removed-images.ts +48 -0
- package/templates/next-base/src/lib/supabase/client.ts +6 -0
- package/templates/next-base/src/lib/supabase/rich-text-image-sync.ts +28 -0
- package/templates/next-base/src/lib/supabase/storage-config.ts +69 -0
- package/templates/next-base/src/lib/supabase/storage.ts +164 -0
- package/templates/next-base/src/types/data-table.ts +4 -0
- package/templates/next-base/src/types/index.ts +0 -2
- package/templates/next-base/tsconfig.tsbuildinfo +1 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { PanelLeft } from "lucide-react";
|
|
5
|
+
import { Slot } from "@radix-ui/react-slot";
|
|
6
|
+
import { cn } from "@/utils/cn";
|
|
7
|
+
import { Button } from "@/components/ui/button";
|
|
8
|
+
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
|
9
|
+
import { useIsMobile } from "@/hooks/use-mobile";
|
|
10
|
+
|
|
11
|
+
type SidebarContextValue = {
|
|
12
|
+
open: boolean;
|
|
13
|
+
setOpen: (value: boolean) => void;
|
|
14
|
+
openMobile: boolean;
|
|
15
|
+
setOpenMobile: (value: boolean) => void;
|
|
16
|
+
isMobile: boolean;
|
|
17
|
+
state: "expanded" | "collapsed";
|
|
18
|
+
toggleSidebar: () => void;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const SidebarContext = React.createContext<SidebarContextValue | null>(null);
|
|
22
|
+
|
|
23
|
+
export function useSidebar() {
|
|
24
|
+
const context = React.useContext(SidebarContext);
|
|
25
|
+
if (!context) {
|
|
26
|
+
throw new Error("useSidebar must be used within SidebarProvider");
|
|
27
|
+
}
|
|
28
|
+
return context;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function SidebarProvider({
|
|
32
|
+
defaultOpen = true,
|
|
33
|
+
children,
|
|
34
|
+
}: {
|
|
35
|
+
defaultOpen?: boolean;
|
|
36
|
+
children: React.ReactNode;
|
|
37
|
+
}) {
|
|
38
|
+
const isMobile = useIsMobile();
|
|
39
|
+
const [open, setOpen] = React.useState(defaultOpen);
|
|
40
|
+
const [openMobile, setOpenMobile] = React.useState(false);
|
|
41
|
+
const state = open ? "expanded" : "collapsed";
|
|
42
|
+
|
|
43
|
+
const toggleSidebar = React.useCallback(() => {
|
|
44
|
+
if (isMobile) {
|
|
45
|
+
setOpenMobile((value) => !value);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
setOpen((value) => !value);
|
|
49
|
+
}, [isMobile]);
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<SidebarContext.Provider
|
|
53
|
+
value={{
|
|
54
|
+
open,
|
|
55
|
+
setOpen,
|
|
56
|
+
openMobile,
|
|
57
|
+
setOpenMobile,
|
|
58
|
+
isMobile,
|
|
59
|
+
state,
|
|
60
|
+
toggleSidebar,
|
|
61
|
+
}}
|
|
62
|
+
>
|
|
63
|
+
<div className="group/sidebar-wrapper flex min-h-svh w-full">
|
|
64
|
+
{children}
|
|
65
|
+
</div>
|
|
66
|
+
</SidebarContext.Provider>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function Sidebar({
|
|
71
|
+
className,
|
|
72
|
+
children,
|
|
73
|
+
collapsible = "offcanvas",
|
|
74
|
+
}: React.ComponentProps<"div"> & { collapsible?: "offcanvas" | "none" }) {
|
|
75
|
+
const { isMobile, openMobile, setOpenMobile, state } = useSidebar();
|
|
76
|
+
|
|
77
|
+
if (collapsible === "none") {
|
|
78
|
+
return (
|
|
79
|
+
<aside
|
|
80
|
+
className={cn(
|
|
81
|
+
"flex h-full w-64 flex-col border-r bg-sidebar text-sidebar-foreground",
|
|
82
|
+
className,
|
|
83
|
+
)}
|
|
84
|
+
>
|
|
85
|
+
{children}
|
|
86
|
+
</aside>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (isMobile) {
|
|
91
|
+
return (
|
|
92
|
+
<Sheet open={openMobile} onOpenChange={setOpenMobile}>
|
|
93
|
+
<SheetContent side="left" className={cn("w-[18rem] p-0", className)}>
|
|
94
|
+
<div className="flex h-full flex-col bg-sidebar text-sidebar-foreground">
|
|
95
|
+
{children}
|
|
96
|
+
</div>
|
|
97
|
+
</SheetContent>
|
|
98
|
+
</Sheet>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return (
|
|
103
|
+
<aside
|
|
104
|
+
data-state={state}
|
|
105
|
+
className={cn(
|
|
106
|
+
"hidden h-screen border-r bg-sidebar text-sidebar-foreground md:flex md:flex-col",
|
|
107
|
+
state === "collapsed" ? "w-14" : "w-64",
|
|
108
|
+
className,
|
|
109
|
+
)}
|
|
110
|
+
>
|
|
111
|
+
{children}
|
|
112
|
+
</aside>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function SidebarTrigger({
|
|
117
|
+
className,
|
|
118
|
+
children,
|
|
119
|
+
...props
|
|
120
|
+
}: React.ComponentProps<typeof Button>) {
|
|
121
|
+
const { toggleSidebar } = useSidebar();
|
|
122
|
+
return (
|
|
123
|
+
<Button
|
|
124
|
+
variant="ghost"
|
|
125
|
+
size="icon"
|
|
126
|
+
className={cn("h-8 w-8", className)}
|
|
127
|
+
onClick={(event) => {
|
|
128
|
+
props.onClick?.(event);
|
|
129
|
+
toggleSidebar();
|
|
130
|
+
}}
|
|
131
|
+
{...props}
|
|
132
|
+
>
|
|
133
|
+
{children ?? <PanelLeft className="h-4 w-4" />}
|
|
134
|
+
</Button>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function SidebarHeader({
|
|
139
|
+
className,
|
|
140
|
+
...props
|
|
141
|
+
}: React.ComponentProps<"div">) {
|
|
142
|
+
return <div className={cn("border-b p-3", className)} {...props} />;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function SidebarContent({
|
|
146
|
+
className,
|
|
147
|
+
...props
|
|
148
|
+
}: React.ComponentProps<"div">) {
|
|
149
|
+
return (
|
|
150
|
+
<div className={cn("flex-1 overflow-y-auto p-2", className)} {...props} />
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function SidebarGroup({
|
|
155
|
+
className,
|
|
156
|
+
...props
|
|
157
|
+
}: React.ComponentProps<"div">) {
|
|
158
|
+
return <div className={cn("mb-2", className)} {...props} />;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function SidebarGroupLabel({
|
|
162
|
+
className,
|
|
163
|
+
...props
|
|
164
|
+
}: React.ComponentProps<"div">) {
|
|
165
|
+
return (
|
|
166
|
+
<div
|
|
167
|
+
className={cn(
|
|
168
|
+
"px-2 py-1 text-xs font-medium text-muted-foreground",
|
|
169
|
+
className,
|
|
170
|
+
)}
|
|
171
|
+
{...props}
|
|
172
|
+
/>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function SidebarGroupContent({
|
|
177
|
+
className,
|
|
178
|
+
...props
|
|
179
|
+
}: React.ComponentProps<"div">) {
|
|
180
|
+
return <div className={cn("space-y-1", className)} {...props} />;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function SidebarMenu({
|
|
184
|
+
className,
|
|
185
|
+
...props
|
|
186
|
+
}: React.ComponentProps<"ul">) {
|
|
187
|
+
return <ul className={cn("space-y-1", className)} {...props} />;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function SidebarMenuItem({
|
|
191
|
+
className,
|
|
192
|
+
...props
|
|
193
|
+
}: React.ComponentProps<"li">) {
|
|
194
|
+
return <li className={cn("list-none", className)} {...props} />;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function SidebarMenuButton({
|
|
198
|
+
className,
|
|
199
|
+
asChild = false,
|
|
200
|
+
isActive = false,
|
|
201
|
+
...props
|
|
202
|
+
}: React.ComponentProps<"button"> & { asChild?: boolean; isActive?: boolean }) {
|
|
203
|
+
const Comp = asChild ? Slot : "button";
|
|
204
|
+
return (
|
|
205
|
+
<Comp
|
|
206
|
+
className={cn(
|
|
207
|
+
"flex h-9 w-full items-center gap-2 rounded-md px-2 text-sm hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
|
208
|
+
isActive &&
|
|
209
|
+
"bg-sidebar-primary text-sidebar-primary-foreground hover:bg-sidebar-primary/90",
|
|
210
|
+
className,
|
|
211
|
+
)}
|
|
212
|
+
{...props}
|
|
213
|
+
/>
|
|
214
|
+
);
|
|
215
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type SidebarModule = {
|
|
2
|
+
id: "dashboard" | "example" | "account";
|
|
3
|
+
url: string;
|
|
4
|
+
icon: "layout-dashboard" | "table" | "user";
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export const sidebarModules: SidebarModule[] = [
|
|
8
|
+
{ id: "dashboard", url: "/dashboard", icon: "layout-dashboard" },
|
|
9
|
+
{ id: "example", url: "/example", icon: "table" },
|
|
10
|
+
{ id: "account", url: "/account", icon: "user" },
|
|
11
|
+
];
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useQuery } from "@tanstack/react-query";
|
|
4
|
+
import { api } from "@/lib/api/axios";
|
|
5
|
+
import type { ApiSuccess } from "@/types";
|
|
6
|
+
|
|
7
|
+
type ExampleItem = {
|
|
8
|
+
id: string;
|
|
9
|
+
name: string;
|
|
10
|
+
description?: string | null;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function useExample() {
|
|
14
|
+
return useQuery({
|
|
15
|
+
queryKey: ["example"],
|
|
16
|
+
queryFn: async () => {
|
|
17
|
+
const { data } = await api.get("/api/v1/example");
|
|
18
|
+
return (data as ApiSuccess<{ items: ExampleItem[] }>).data.items;
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
4
|
+
import { api } from "@/lib/api/axios";
|
|
5
|
+
import type { CreateExampleInput } from "@/example/validations";
|
|
6
|
+
import type { ApiSuccess } from "@/types";
|
|
7
|
+
|
|
8
|
+
export function useCreateExample() {
|
|
9
|
+
const queryClient = useQueryClient();
|
|
10
|
+
|
|
11
|
+
return useMutation({
|
|
12
|
+
mutationFn: async (payload: CreateExampleInput) => {
|
|
13
|
+
const { data } = await api.post("/api/v1/example", payload);
|
|
14
|
+
return (data as ApiSuccess<{ id: string }>).data;
|
|
15
|
+
},
|
|
16
|
+
onSuccess: async () => {
|
|
17
|
+
await queryClient.invalidateQueries({ queryKey: ["example"] });
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useMemo } from "react";
|
|
4
|
+
import { useTranslations } from "next-intl";
|
|
5
|
+
import {
|
|
6
|
+
createColumnHelper,
|
|
7
|
+
getCoreRowModel,
|
|
8
|
+
getPaginationRowModel,
|
|
9
|
+
useReactTable,
|
|
10
|
+
} from "@tanstack/react-table";
|
|
11
|
+
import { useExample } from "@/example/api/use-example";
|
|
12
|
+
import { DataTable } from "@/components/ui/data-table/data-table";
|
|
13
|
+
|
|
14
|
+
type ExampleItem = {
|
|
15
|
+
id: string;
|
|
16
|
+
name: string;
|
|
17
|
+
description?: string | null;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const columnHelper = createColumnHelper<ExampleItem>();
|
|
21
|
+
|
|
22
|
+
export function ExampleTable() {
|
|
23
|
+
const t = useTranslations("example.table");
|
|
24
|
+
const { data, isLoading } = useExample();
|
|
25
|
+
const rows = useMemo(() => (Array.isArray(data) ? data : []), [data]);
|
|
26
|
+
|
|
27
|
+
const translatedColumns = useMemo(
|
|
28
|
+
() => [
|
|
29
|
+
columnHelper.accessor("name", {
|
|
30
|
+
header: t("name"),
|
|
31
|
+
}),
|
|
32
|
+
columnHelper.accessor("description", {
|
|
33
|
+
header: t("description"),
|
|
34
|
+
}),
|
|
35
|
+
],
|
|
36
|
+
[t],
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
const table = useReactTable({
|
|
40
|
+
data: rows,
|
|
41
|
+
columns: translatedColumns,
|
|
42
|
+
getCoreRowModel: getCoreRowModel(),
|
|
43
|
+
getPaginationRowModel: getPaginationRowModel(),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
if (isLoading) {
|
|
47
|
+
return <p>{t("loading")}</p>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return <DataTable table={table} />;
|
|
51
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { useTranslations } from "next-intl";
|
|
5
|
+
import { protectedApi } from "@/lib/api/axios";
|
|
6
|
+
import type { ApiSuccess } from "@/types";
|
|
7
|
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
8
|
+
|
|
9
|
+
type MePayload = {
|
|
10
|
+
user?: {
|
|
11
|
+
id: string;
|
|
12
|
+
username: string;
|
|
13
|
+
email?: string | null;
|
|
14
|
+
name?: string | null;
|
|
15
|
+
role?: { name: string; level: number } | null;
|
|
16
|
+
};
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function AccountPanel() {
|
|
20
|
+
const t = useTranslations("auth.account");
|
|
21
|
+
const [session, setSession] = useState<MePayload | null>(null);
|
|
22
|
+
const [loading, setLoading] = useState(true);
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
let mounted = true;
|
|
26
|
+
const run = async () => {
|
|
27
|
+
try {
|
|
28
|
+
const response = await protectedApi.get("/api/v1/auth/me", {
|
|
29
|
+
withCredentials: true,
|
|
30
|
+
});
|
|
31
|
+
if (mounted) {
|
|
32
|
+
setSession((response.data as ApiSuccess<MePayload>).data);
|
|
33
|
+
}
|
|
34
|
+
} catch {
|
|
35
|
+
if (mounted) {
|
|
36
|
+
setSession(null);
|
|
37
|
+
}
|
|
38
|
+
} finally {
|
|
39
|
+
if (mounted) {
|
|
40
|
+
setLoading(false);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
void run();
|
|
46
|
+
return () => {
|
|
47
|
+
mounted = false;
|
|
48
|
+
};
|
|
49
|
+
}, []);
|
|
50
|
+
|
|
51
|
+
if (loading) {
|
|
52
|
+
return <p>{t("loading")}</p>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!session?.user) {
|
|
56
|
+
return <p>{t("noSession")}</p>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<Card className="max-w-xl">
|
|
61
|
+
<CardHeader>
|
|
62
|
+
<CardTitle>{t("title")}</CardTitle>
|
|
63
|
+
</CardHeader>
|
|
64
|
+
<CardContent className="space-y-2 text-sm">
|
|
65
|
+
<p>
|
|
66
|
+
{t("userId")}: {session.user.id}
|
|
67
|
+
</p>
|
|
68
|
+
<p>
|
|
69
|
+
{t("username")}: {session.user.username}
|
|
70
|
+
</p>
|
|
71
|
+
<p>
|
|
72
|
+
{t("email")}: {session.user.email ?? t("na")}
|
|
73
|
+
</p>
|
|
74
|
+
<p>
|
|
75
|
+
{t("name")}: {session.user.name ?? t("na")}
|
|
76
|
+
</p>
|
|
77
|
+
</CardContent>
|
|
78
|
+
</Card>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import type { FormEvent } from "react";
|
|
5
|
+
import { useRouter } from "next/navigation";
|
|
6
|
+
import { useTranslations } from "next-intl";
|
|
7
|
+
import { toast } from "sonner";
|
|
8
|
+
import { protectedApi } from "@/lib/api/axios";
|
|
9
|
+
import { changePasswordSchema } from "@/features/auth/validations";
|
|
10
|
+
import { Button } from "@/components/ui/button";
|
|
11
|
+
import { Card, CardContent } from "@/components/ui/card";
|
|
12
|
+
import { Input } from "@/components/ui/input";
|
|
13
|
+
import { Label } from "@/components/ui/label";
|
|
14
|
+
|
|
15
|
+
export function ChangePasswordForm() {
|
|
16
|
+
const router = useRouter();
|
|
17
|
+
const t = useTranslations("auth.changePasswordForm");
|
|
18
|
+
const [currentPassword, setCurrentPassword] = useState("");
|
|
19
|
+
const [newPassword, setNewPassword] = useState("");
|
|
20
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
21
|
+
|
|
22
|
+
const onSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
|
23
|
+
event.preventDefault();
|
|
24
|
+
|
|
25
|
+
const parsed = changePasswordSchema.safeParse({
|
|
26
|
+
currentPassword,
|
|
27
|
+
newPassword,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
if (!parsed.success) {
|
|
31
|
+
toast.error(t("invalidInput"));
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
setIsSubmitting(true);
|
|
37
|
+
await protectedApi.post("/api/v1/auth/change-password", parsed.data, {
|
|
38
|
+
withCredentials: true,
|
|
39
|
+
});
|
|
40
|
+
toast.success(t("success"));
|
|
41
|
+
router.push("/dashboard");
|
|
42
|
+
router.refresh();
|
|
43
|
+
} catch (error) {
|
|
44
|
+
const message = error instanceof Error ? error.message : t("failed");
|
|
45
|
+
toast.error(message);
|
|
46
|
+
} finally {
|
|
47
|
+
setIsSubmitting(false);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<Card>
|
|
53
|
+
<CardContent className="pt-6">
|
|
54
|
+
<form onSubmit={onSubmit} className="grid gap-4">
|
|
55
|
+
<div className="grid gap-2">
|
|
56
|
+
<Label htmlFor="currentPassword">{t("currentPassword")}</Label>
|
|
57
|
+
<Input
|
|
58
|
+
id="currentPassword"
|
|
59
|
+
type="password"
|
|
60
|
+
value={currentPassword}
|
|
61
|
+
onChange={(event) => setCurrentPassword(event.target.value)}
|
|
62
|
+
required
|
|
63
|
+
/>
|
|
64
|
+
</div>
|
|
65
|
+
<div className="grid gap-2">
|
|
66
|
+
<Label htmlFor="newPassword">{t("newPassword")}</Label>
|
|
67
|
+
<Input
|
|
68
|
+
id="newPassword"
|
|
69
|
+
type="password"
|
|
70
|
+
value={newPassword}
|
|
71
|
+
onChange={(event) => setNewPassword(event.target.value)}
|
|
72
|
+
required
|
|
73
|
+
/>
|
|
74
|
+
</div>
|
|
75
|
+
<Button type="submit" disabled={isSubmitting}>
|
|
76
|
+
{isSubmitting ? t("submitting") : t("submit")}
|
|
77
|
+
</Button>
|
|
78
|
+
</form>
|
|
79
|
+
</CardContent>
|
|
80
|
+
</Card>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import type { FormEvent } from "react";
|
|
5
|
+
import { useRouter } from "next/navigation";
|
|
6
|
+
import { useTranslations } from "next-intl";
|
|
7
|
+
import { toast } from "sonner";
|
|
8
|
+
import { publicApi } from "@/lib/api/axios";
|
|
9
|
+
import { setAccessToken } from "@/lib/api/token-store";
|
|
10
|
+
import { signInSchema } from "@/features/auth/validations";
|
|
11
|
+
import type { ApiSuccess } from "@/types";
|
|
12
|
+
import { Button } from "@/components/ui/button";
|
|
13
|
+
import { Card, CardContent } from "@/components/ui/card";
|
|
14
|
+
import { Input } from "@/components/ui/input";
|
|
15
|
+
import { Label } from "@/components/ui/label";
|
|
16
|
+
|
|
17
|
+
type LoginResponse = {
|
|
18
|
+
accessToken: string;
|
|
19
|
+
requirePasswordChange: boolean;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export function SignInForm() {
|
|
23
|
+
const router = useRouter();
|
|
24
|
+
const t = useTranslations("auth.signInForm");
|
|
25
|
+
const [username, setUsername] = useState("");
|
|
26
|
+
const [password, setPassword] = useState("");
|
|
27
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
28
|
+
|
|
29
|
+
const onSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
|
30
|
+
event.preventDefault();
|
|
31
|
+
|
|
32
|
+
const parsed = signInSchema.safeParse({ username, password });
|
|
33
|
+
if (!parsed.success) {
|
|
34
|
+
toast.error(t("invalidInput"));
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
setIsSubmitting(true);
|
|
40
|
+
const response = await publicApi.post("/api/v1/auth/login", parsed.data, {
|
|
41
|
+
withCredentials: true,
|
|
42
|
+
});
|
|
43
|
+
const payload = (response.data as ApiSuccess<LoginResponse>).data;
|
|
44
|
+
if (!payload?.accessToken) {
|
|
45
|
+
toast.error(t("missingAccessToken"));
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
setAccessToken(payload.accessToken);
|
|
50
|
+
toast.success(t("success"));
|
|
51
|
+
router.push(payload.requirePasswordChange ? "/change-password" : "/dashboard");
|
|
52
|
+
router.refresh();
|
|
53
|
+
} catch (error) {
|
|
54
|
+
const message = error instanceof Error ? error.message : t("failed");
|
|
55
|
+
toast.error(message);
|
|
56
|
+
} finally {
|
|
57
|
+
setIsSubmitting(false);
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<Card>
|
|
63
|
+
<CardContent className="pt-6">
|
|
64
|
+
<form onSubmit={onSubmit} className="grid gap-4">
|
|
65
|
+
<div className="grid gap-2">
|
|
66
|
+
<Label htmlFor="username">{t("username")}</Label>
|
|
67
|
+
<Input
|
|
68
|
+
id="username"
|
|
69
|
+
value={username}
|
|
70
|
+
onChange={(event) => setUsername(event.target.value)}
|
|
71
|
+
placeholder={t("usernamePlaceholder")}
|
|
72
|
+
autoComplete="username"
|
|
73
|
+
required
|
|
74
|
+
/>
|
|
75
|
+
</div>
|
|
76
|
+
<div className="grid gap-2">
|
|
77
|
+
<Label htmlFor="password">{t("password")}</Label>
|
|
78
|
+
<Input
|
|
79
|
+
id="password"
|
|
80
|
+
type="password"
|
|
81
|
+
value={password}
|
|
82
|
+
onChange={(event) => setPassword(event.target.value)}
|
|
83
|
+
placeholder={t("passwordPlaceholder")}
|
|
84
|
+
autoComplete="current-password"
|
|
85
|
+
required
|
|
86
|
+
/>
|
|
87
|
+
</div>
|
|
88
|
+
<Button type="submit" disabled={isSubmitting}>
|
|
89
|
+
{isSubmitting ? t("submitting") : t("submit")}
|
|
90
|
+
</Button>
|
|
91
|
+
</form>
|
|
92
|
+
</CardContent>
|
|
93
|
+
</Card>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export const signInSchema = z.object({
|
|
4
|
+
username: z.string().min(3).max(30),
|
|
5
|
+
password: z.string().min(8),
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
export const changePasswordSchema = z.object({
|
|
9
|
+
currentPassword: z.string().min(8),
|
|
10
|
+
newPassword: z.string().min(8),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
export type SignInInput = z.infer<typeof signInSchema>;
|
|
14
|
+
export type ChangePasswordInput = z.infer<typeof changePasswordSchema>;
|