diff --git a/package.json b/package.json index 68db43d..c3d1f64 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,12 @@ "lint": "next lint" }, "dependencies": { + "@hookform/resolvers": "^3.9.1", + "@radix-ui/react-checkbox": "^1.1.2", + "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-select": "^2.1.2", "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-toast": "^1.2.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cross-fetch": "^4.0.0", @@ -20,8 +25,11 @@ "pino-pretty": "^13.0.0", "react": "19.0.0-rc-66855b96-20241106", "react-dom": "19.0.0-rc-66855b96-20241106", + "react-hook-form": "^7.53.2", "tailwind-merge": "^2.5.5", - "tailwindcss-animate": "^1.0.7" + "tailwindcss-animate": "^1.0.7", + "zod": "^3.23.8", + "zustand": "^5.0.1" }, "devDependencies": { "typescript": "^5", diff --git a/src/app/(main)/gitea/admin/users/page.tsx b/src/app/(main)/gitea/admin/users/page.tsx new file mode 100644 index 0000000..7e47f43 --- /dev/null +++ b/src/app/(main)/gitea/admin/users/page.tsx @@ -0,0 +1,230 @@ +"use client"; + +import { z } from "zod"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import logger from "@/lib/logger"; +import { Loader2 } from "lucide-react"; +import { useForm } from "react-hook-form"; +import { useToast } from "@/hooks/use-toast"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { ToastAction } from "@/components/ui/toast"; +import { zodResolver } from "@hookform/resolvers/zod"; +import adminCreateUserStore from "@/app/actions/(gitea)/admin/users/store"; + +const formSchema = z.object({ + source_id: z.coerce.number().int().nonnegative().default(0), + visibility: z.enum(["public", "limited", "private"]).default("public"), + username: z + .string() + .min(1, "Username cannot be empty") + .max(40, "Username is too long (max 40 characters)"), + email: z + .string() + .email("Invalid email address") + .max(254, "Email is too long (max 254 characters)"), + password: z + .string() + .min(8, "Password must be at least 8 characters") + .max(255, "Password must be at most 255 characters"), + must_change_password: z.coerce.boolean().default(true), +}); + +export default function AdminCreateUserForm() { + const { toast } = useToast(); + const { user, error, loading, adminCreateUser } = adminCreateUserStore(); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + source_id: 0, + visibility: "public", + username: "", + email: "", + password: "", + must_change_password: true, + }, + }); + + function onSubmit(values: z.infer) { + logger.info({ values }, "submitting form"); + adminCreateUser(values); + if (user) { + toast({ + variant: "default", + title: "User created", + description: `User ${user.login} created successfully`, + action: ( + Create another + ), + }); + } else if (error) { + toast({ + variant: "destructive", + title: "Failed to create user", + description: `${error.message}`, + action: Try again, + }); + } + } + + return ( +
+ + + Gitea + Admin Create User Card + + +
+ + ( + + Authentication Source + + + + )} + /> + ( + + User visibility + + + + )} + /> + ( + + Username + + + + + + )} + /> + ( + + Email Address + + + + + + )} + /> + ( + + Password + + + + + + )} + /> + ( + + + + +
+ Require user to change password + (recommended) + +
+
+ )} + /> + + + +
+
+
+ ); +} diff --git a/src/app/actions/(gitea)/admin/users/store.ts b/src/app/actions/(gitea)/admin/users/store.ts new file mode 100644 index 0000000..f838df4 --- /dev/null +++ b/src/app/actions/(gitea)/admin/users/store.ts @@ -0,0 +1,33 @@ +import { create } from "zustand"; +import logger from "@/lib/logger"; +import { adminCreateUser } from "."; +import { APIError, CreateUserOption, User } from "gitea-js"; + +interface AdminCreateUserState { + user: User | null; + error: APIError | null; + loading: boolean; + adminCreateUser: (body: CreateUserOption) => Promise; +} + +const adminCreateUserStore = create((set) => ({ + user: null, + error: null, + loading: false, + adminCreateUser: async (body: CreateUserOption) => { + set({ loading: true, user: null, error: null }); + try { + const result = await adminCreateUser(body); + if (result.error) { + set({ loading: false, user: null, error: result.error }); + } else { + set({ loading: false, user: result.data, error: null }); + } + } catch (error) { + logger.error({ error }, "Error in adminCreateUserStore"); + set({ loading: false, user: null, error: error as APIError }); + } + }, +})); + +export default adminCreateUserStore; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 34eb3e7..7145b9a 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -2,6 +2,7 @@ import "@/app/globals.css"; import { cn } from "@/lib/utils"; import type { Metadata } from "next"; import { Inter } from "next/font/google"; +import { Toaster } from "@/components/ui/toaster"; const inter = Inter({ subsets: ["latin"], variable: "--font-sans" }); @@ -22,6 +23,7 @@ export default function RootLayout({ className={cn("font-sans antialiased flex min-h-full", inter.variable)} >
{children}
+ ); diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx new file mode 100644 index 0000000..65d4fcd --- /dev/null +++ b/src/components/ui/button.tsx @@ -0,0 +1,57 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + { + variants: { + variant: { + default: + "bg-primary text-primary-foreground shadow hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", + outline: + "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2", + sm: "h-8 rounded-md px-3 text-xs", + lg: "h-10 rounded-md px-8", + icon: "h-9 w-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button" + return ( + + ) + } +) +Button.displayName = "Button" + +export { Button, buttonVariants } diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx new file mode 100644 index 0000000..cabfbfc --- /dev/null +++ b/src/components/ui/card.tsx @@ -0,0 +1,76 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +Card.displayName = "Card" + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardHeader.displayName = "CardHeader" + +const CardTitle = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardTitle.displayName = "CardTitle" + +const CardDescription = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardDescription.displayName = "CardDescription" + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardContent.displayName = "CardContent" + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardFooter.displayName = "CardFooter" + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } diff --git a/src/components/ui/checkbox.tsx b/src/components/ui/checkbox.tsx new file mode 100644 index 0000000..c6fdd07 --- /dev/null +++ b/src/components/ui/checkbox.tsx @@ -0,0 +1,30 @@ +"use client" + +import * as React from "react" +import * as CheckboxPrimitive from "@radix-ui/react-checkbox" +import { Check } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Checkbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)) +Checkbox.displayName = CheckboxPrimitive.Root.displayName + +export { Checkbox } diff --git a/src/components/ui/form.tsx b/src/components/ui/form.tsx new file mode 100644 index 0000000..b6daa65 --- /dev/null +++ b/src/components/ui/form.tsx @@ -0,0 +1,178 @@ +"use client" + +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" +import { Slot } from "@radix-ui/react-slot" +import { + Controller, + ControllerProps, + FieldPath, + FieldValues, + FormProvider, + useFormContext, +} from "react-hook-form" + +import { cn } from "@/lib/utils" +import { Label } from "@/components/ui/label" + +const Form = FormProvider + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +> = { + name: TName +} + +const FormFieldContext = React.createContext( + {} as FormFieldContextValue +) + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +>({ + ...props +}: ControllerProps) => { + return ( + + + + ) +} + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext) + const itemContext = React.useContext(FormItemContext) + const { getFieldState, formState } = useFormContext() + + const fieldState = getFieldState(fieldContext.name, formState) + + if (!fieldContext) { + throw new Error("useFormField should be used within ") + } + + const { id } = itemContext + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + } +} + +type FormItemContextValue = { + id: string +} + +const FormItemContext = React.createContext( + {} as FormItemContextValue +) + +const FormItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const id = React.useId() + + return ( + +
+ + ) +}) +FormItem.displayName = "FormItem" + +const FormLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { error, formItemId } = useFormField() + + return ( +