@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.
Files changed (114) hide show
  1. package/README.md +37 -24
  2. package/dist/cli.js +168 -107
  3. package/package.json +5 -3
  4. package/templates/features/supabase/src/lib/supabase/rich-text-image-sync.ts +28 -0
  5. package/templates/next-base/.env +16 -0
  6. package/templates/next-base/.env.development +16 -0
  7. package/templates/next-base/.env.example +16 -0
  8. package/templates/next-base/PROJECT_STRUCTURE.md +29 -18
  9. package/templates/next-base/SETUP.md +62 -10
  10. package/templates/next-base/bun.lock +59 -414
  11. package/templates/next-base/messages/vi/auth.json +42 -0
  12. package/templates/next-base/messages/vi/common.json +34 -0
  13. package/templates/next-base/messages/vi/example.json +10 -0
  14. package/templates/next-base/next-env.d.ts +1 -1
  15. package/templates/next-base/next.config.ts +4 -1
  16. package/templates/next-base/nextcli.json +12 -4
  17. package/templates/next-base/package.json +25 -1
  18. package/templates/next-base/prisma/schema.prisma +84 -0
  19. package/templates/next-base/prisma.config.ts +16 -0
  20. package/templates/next-base/src/app/(auth)/.gitkeep +1 -0
  21. package/templates/next-base/src/app/(auth)/change-password/layout.tsx +21 -0
  22. package/templates/next-base/src/app/(auth)/change-password/page.tsx +14 -0
  23. package/templates/next-base/src/app/(auth)/layout.tsx +9 -0
  24. package/templates/next-base/src/app/(auth)/sign-in/layout.tsx +17 -0
  25. package/templates/next-base/src/app/(auth)/sign-in/page.tsx +14 -0
  26. package/templates/next-base/src/app/(dashboard)/account/page.tsx +18 -0
  27. package/templates/next-base/src/app/(dashboard)/dashboard/page.tsx +17 -0
  28. package/templates/next-base/src/app/(dashboard)/example/page.tsx +13 -0
  29. package/templates/next-base/src/app/(dashboard)/layout.tsx +22 -0
  30. package/templates/next-base/src/app/api/auth/[...all]/route.ts +4 -0
  31. package/templates/next-base/src/app/api/v1/auth/change-password/route.ts +55 -0
  32. package/templates/next-base/src/app/api/v1/auth/login/route.ts +70 -0
  33. package/templates/next-base/src/app/api/v1/auth/logout/route.ts +28 -0
  34. package/templates/next-base/src/app/api/v1/auth/me/route.ts +24 -0
  35. package/templates/next-base/src/app/api/v1/auth/refresh/route.ts +32 -0
  36. package/templates/next-base/src/app/api/v1/example/route.ts +34 -0
  37. package/templates/next-base/src/app/api/v1/users/[id]/route.ts +104 -0
  38. package/templates/next-base/src/app/api/v1/users/route.ts +58 -0
  39. package/templates/next-base/src/app/blog-demo/page.tsx +9 -0
  40. package/templates/next-base/src/app/globals.css +57 -0
  41. package/templates/next-base/src/app/layout.tsx +14 -6
  42. package/templates/next-base/src/app/page.tsx +2 -25
  43. package/templates/next-base/src/components/layout/private/app-sidebar.tsx +44 -0
  44. package/templates/next-base/src/components/layout/private/dashboard-layout.tsx +54 -0
  45. package/templates/next-base/src/components/layout/private/locale-switcher.tsx +45 -0
  46. package/templates/next-base/src/components/layout/private/nav-sidebar.tsx +55 -0
  47. package/templates/next-base/src/components/layout/private/nav-user.tsx +99 -0
  48. package/templates/next-base/src/components/providers/query-provider.tsx +17 -0
  49. package/templates/next-base/src/components/rich-text/adapters/textarea-field.tsx +50 -0
  50. package/templates/next-base/src/components/rich-text/client-only.tsx +23 -0
  51. package/templates/next-base/src/components/rich-text/editor-field.tsx +62 -0
  52. package/templates/next-base/src/components/rich-text/examples/blog-rich-text-demo.tsx +218 -0
  53. package/templates/next-base/src/components/rich-text/index.ts +11 -0
  54. package/templates/next-base/src/components/rich-text/lexical/extension.ts +37 -0
  55. package/templates/next-base/src/components/rich-text/lexical/nodes/image-node.tsx +187 -0
  56. package/templates/next-base/src/components/rich-text/lexical/plugins/image-plugin.tsx +40 -0
  57. package/templates/next-base/src/components/rich-text/lexical/plugins/initial-state-plugin.tsx +26 -0
  58. package/templates/next-base/src/components/rich-text/lexical/plugins/on-change-plugin.tsx +26 -0
  59. package/templates/next-base/src/components/rich-text/lexical/plugins/toolbar-plugin.tsx +190 -0
  60. package/templates/next-base/src/components/rich-text/lexical/rich-text-editor.tsx +121 -0
  61. package/templates/next-base/src/components/rich-text/lexical/theme.ts +18 -0
  62. package/templates/next-base/src/components/rich-text/rich-text-renderer.tsx +72 -0
  63. package/templates/next-base/src/components/rich-text/types.ts +60 -0
  64. package/templates/next-base/src/components/ui/data-table/data-table-column-header.tsx +23 -0
  65. package/templates/next-base/src/components/ui/data-table/data-table-filter-list.tsx +3 -0
  66. package/templates/next-base/src/components/ui/data-table/data-table-pagination.tsx +35 -0
  67. package/templates/next-base/src/components/ui/data-table/data-table-skeleton.tsx +11 -0
  68. package/templates/next-base/src/components/ui/data-table/data-table-toolbar.tsx +14 -0
  69. package/templates/next-base/src/components/ui/data-table/data-table-view-options.tsx +3 -0
  70. package/templates/next-base/src/components/ui/data-table/data-table.tsx +72 -0
  71. package/templates/next-base/src/components/ui/sidebar.tsx +215 -0
  72. package/templates/next-base/src/data/sidebar-modules.ts +11 -0
  73. package/templates/next-base/src/example/api/use-example.ts +21 -0
  74. package/templates/next-base/src/example/api/use-mutations.ts +20 -0
  75. package/templates/next-base/src/example/components/example-table.tsx +51 -0
  76. package/templates/next-base/src/example/services.ts +9 -0
  77. package/templates/next-base/src/example/validations.ts +8 -0
  78. package/templates/next-base/src/features/auth/components/account-panel.tsx +80 -0
  79. package/templates/next-base/src/features/auth/components/change-password-form.tsx +82 -0
  80. package/templates/next-base/src/features/auth/components/sign-in-form.tsx +95 -0
  81. package/templates/next-base/src/features/auth/validations.ts +14 -0
  82. package/templates/next-base/src/features/users/services.ts +132 -0
  83. package/templates/next-base/src/features/users/validations.ts +21 -0
  84. package/templates/next-base/src/hooks/index.ts +1 -1
  85. package/templates/next-base/src/hooks/table/use-data-table.ts +33 -0
  86. package/templates/next-base/src/hooks/use-mobile.ts +25 -0
  87. package/templates/next-base/src/i18n/config.ts +7 -0
  88. package/templates/next-base/src/i18n/namespaces.ts +5 -0
  89. package/templates/next-base/src/i18n/request.ts +25 -0
  90. package/templates/next-base/src/instrumentation.ts +14 -0
  91. package/templates/next-base/src/lib/api/axios.ts +145 -0
  92. package/templates/next-base/src/lib/api/response.ts +45 -0
  93. package/templates/next-base/src/lib/api/token-store.ts +13 -0
  94. package/templates/next-base/src/lib/auth/bootstrap.ts +95 -0
  95. package/templates/next-base/src/lib/auth/client.ts +7 -0
  96. package/templates/next-base/src/lib/auth/cookies.ts +15 -0
  97. package/templates/next-base/src/lib/auth/index.ts +1 -0
  98. package/templates/next-base/src/lib/auth/rbac.ts +59 -0
  99. package/templates/next-base/src/lib/auth/server.ts +21 -0
  100. package/templates/next-base/src/lib/constants.ts +10 -0
  101. package/templates/next-base/src/lib/db/prisma.ts +23 -0
  102. package/templates/next-base/src/lib/prisma.ts +23 -0
  103. package/templates/next-base/src/lib/rich-text/default-image-removal.ts +10 -0
  104. package/templates/next-base/src/lib/rich-text/image-urls.ts +41 -0
  105. package/templates/next-base/src/lib/rich-text/index.ts +12 -0
  106. package/templates/next-base/src/lib/rich-text/supabase-url.ts +67 -0
  107. package/templates/next-base/src/lib/rich-text/sync-removed-images.ts +48 -0
  108. package/templates/next-base/src/lib/supabase/client.ts +6 -0
  109. package/templates/next-base/src/lib/supabase/rich-text-image-sync.ts +28 -0
  110. package/templates/next-base/src/lib/supabase/storage-config.ts +69 -0
  111. package/templates/next-base/src/lib/supabase/storage.ts +164 -0
  112. package/templates/next-base/src/types/data-table.ts +4 -0
  113. package/templates/next-base/src/types/index.ts +0 -2
  114. 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,9 @@
1
+ import prisma from "@/lib/db/prisma";
2
+
3
+ export async function listExamples() {
4
+ return prisma.example.findMany({
5
+ orderBy: {
6
+ createdAt: "desc",
7
+ },
8
+ });
9
+ }
@@ -0,0 +1,8 @@
1
+ import { z } from "zod";
2
+
3
+ export const createExampleSchema = z.object({
4
+ name: z.string().min(2),
5
+ description: z.string().optional(),
6
+ });
7
+
8
+ export type CreateExampleInput = z.infer<typeof createExampleSchema>;
@@ -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>;