diff --git a/backend/dashboard/src/app.tsx b/backend/dashboard/src/app.tsx index 669b8eebc4..aa0489ec23 100644 --- a/backend/dashboard/src/app.tsx +++ b/backend/dashboard/src/app.tsx @@ -2,9 +2,10 @@ import { createInertiaApp } from "@inertiajs/react"; import { createRoot } from "react-dom/client"; import Dashboard from "./pages/Dashboard"; +import Login from "./pages/Login"; import "./styles.css"; -const pages = { Dashboard }; +const pages = { Dashboard, Login }; createInertiaApp({ resolve: (name) => { diff --git a/backend/dashboard/src/components/app-sidebar.tsx b/backend/dashboard/src/components/app-sidebar.tsx index a6fb99a6c1..380a06fa0e 100644 --- a/backend/dashboard/src/components/app-sidebar.tsx +++ b/backend/dashboard/src/components/app-sidebar.tsx @@ -1,9 +1,12 @@ import { Link } from "@inertiajs/react"; import { LayoutDashboard } from "lucide-react"; +import { NavUser, type NavUserData } from "@/components/nav-user"; + import { Sidebar, SidebarContent, + SidebarFooter, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, @@ -14,7 +17,7 @@ import { SidebarRail, } from "@/components/ui/sidebar"; -export function AppSidebar() { +export function AppSidebar({ user }: { user: NavUserData }) { return ( @@ -60,6 +63,10 @@ export function AppSidebar() { + + + + ); diff --git a/backend/dashboard/src/components/login-form.tsx b/backend/dashboard/src/components/login-form.tsx new file mode 100644 index 0000000000..74ad25d408 --- /dev/null +++ b/backend/dashboard/src/components/login-form.tsx @@ -0,0 +1,227 @@ +import { useState } from "react"; +import type { FormEvent } from "react"; + +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Field, + FieldDescription, + FieldError, + FieldGroup, + FieldLabel, +} from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { cn } from "@/lib/utils"; + +const LOGIN_MUTATION = ` + mutation DashboardLogin($input: LoginInput!) { + login(input: $input) { + __typename + ... on LoginSuccess { + user { + id + } + } + ... on LoginErrors { + errors { + email + password + } + } + ... on WrongEmailOrPassword { + message + } + } + } +`; + +type LoginErrors = { + email: string[]; + password: string[]; + form: string[]; +}; + +type LoginResponse = { + data?: { + login?: + | { __typename: "LoginSuccess" } + | { + __typename: "LoginErrors"; + errors: { email: string[]; password: string[] }; + } + | { __typename: "WrongEmailOrPassword"; message: string }; + }; + errors?: Array<{ message: string }>; +}; + +export function LoginForm({ + className, + nextUrl, + ...props +}: React.ComponentProps<"div"> & { nextUrl: string }) { + const [errors, setErrors] = useState({ + email: [], + password: [], + form: [], + }); + const [isSubmitting, setIsSubmitting] = useState(false); + + async function logIn(event: FormEvent) { + event.preventDefault(); + setErrors({ email: [], password: [], form: [] }); + setIsSubmitting(true); + + const formData = new FormData(event.currentTarget); + + try { + const response = await fetch("/graphql", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "same-origin", + body: JSON.stringify({ + query: LOGIN_MUTATION, + variables: { + input: { + email: formData.get("email"), + password: formData.get("password"), + }, + }, + }), + }); + + if (!response.ok) { + throw new Error(`Login request failed with status ${response.status}`); + } + + const payload = (await response.json()) as LoginResponse; + + if (payload.errors?.length) { + setErrors({ + email: [], + password: [], + form: payload.errors.map((error) => error.message), + }); + return; + } + + const result = payload.data?.login; + + if (result?.__typename === "LoginSuccess") { + window.location.assign(nextUrl); + return; + } + + if (result?.__typename === "LoginErrors") { + setErrors({ ...result.errors, form: [] }); + return; + } + + if (result?.__typename === "WrongEmailOrPassword") { + setErrors({ + email: [], + password: ["The email or password is incorrect."], + form: [], + }); + return; + } + + throw new Error("Login response did not contain a result"); + } catch { + setErrors({ + email: [], + password: [], + form: ["We couldn't log you in. Please try again."], + }); + } finally { + setIsSubmitting(false); + } + } + + return ( +
+ + + Welcome back + + Sign in with your email and password. + + + +
+ + 0}> + Email + 0} + autoComplete="email" + disabled={isSubmitting} + id="email" + name="email" + type="email" + placeholder="m@example.com" + required + /> + ({ message }))} + id="email-error" + /> + + 0}> + + 0} + autoComplete="current-password" + disabled={isSubmitting} + id="password" + name="password" + type="password" + required + /> + ({ message }))} + id="password-error" + /> + + + + ({ message }))} + /> + + Don't have an account? Sign up + + + +
+
+
+ + By clicking continue, you agree to our{" "} + Terms of Service and{" "} + Privacy Policy. + +
+ ); +} diff --git a/backend/dashboard/src/components/nav-user.tsx b/backend/dashboard/src/components/nav-user.tsx new file mode 100644 index 0000000000..12f4f4a5da --- /dev/null +++ b/backend/dashboard/src/components/nav-user.tsx @@ -0,0 +1,117 @@ +import { BadgeCheckIcon, ChevronsUpDownIcon, LogOutIcon } from "lucide-react"; + +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + useSidebar, +} from "@/components/ui/sidebar"; + +export type NavUserData = { + name: string; + email: string; + avatar: string | null; +}; + +function getInitials(name: string) { + return ( + name + .split(/\s+/) + .filter(Boolean) + .slice(0, 2) + .map((part) => part[0]?.toUpperCase()) + .join("") || "?" + ); +} + +export function NavUser({ user }: { user: NavUserData }) { + const { isMobile } = useSidebar(); + const initials = getInitials(user.name); + + async function logOut() { + const response = await fetch("/graphql", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + query: "mutation DashboardLogout { logout { ok } }", + }), + }); + + if (response.ok) { + window.location.assign("/dashboard/login"); + } + } + + return ( + + + + + + + {user.avatar ? ( + + ) : null} + + {initials} + + +
+
{user.name}
+
{user.email}
+
+ +
+
+ + +
+ + {user.avatar ? ( + + ) : null} + + {initials} + + +
+
{user.name}
+
{user.email}
+
+
+
+ + + + + Account + + + + void logOut()}> + + Log out + +
+
+
+
+ ); +} diff --git a/backend/dashboard/src/components/ui/avatar.tsx b/backend/dashboard/src/components/ui/avatar.tsx new file mode 100644 index 0000000000..df19c2505e --- /dev/null +++ b/backend/dashboard/src/components/ui/avatar.tsx @@ -0,0 +1,110 @@ +import { Avatar as AvatarPrimitive } from "radix-ui"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Avatar({ + className, + size = "default", + ...props +}: React.ComponentProps & { + size?: "default" | "sm" | "lg"; +}) { + return ( + + ); +} + +function AvatarImage({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AvatarFallback({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) { + return ( + svg]:hidden", + "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2", + "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2", + className, + )} + {...props} + /> + ); +} + +function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AvatarGroupCount({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3", + className, + )} + {...props} + /> + ); +} + +export { + Avatar, + AvatarImage, + AvatarFallback, + AvatarGroup, + AvatarGroupCount, + AvatarBadge, +}; diff --git a/backend/dashboard/src/components/ui/card.tsx b/backend/dashboard/src/components/ui/card.tsx new file mode 100644 index 0000000000..4d7645bd27 --- /dev/null +++ b/backend/dashboard/src/components/ui/card.tsx @@ -0,0 +1,103 @@ +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Card({ + className, + size = "default", + ...props +}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) { + return ( +
img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl", + className, + )} + {...props} + /> + ); +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +}; diff --git a/backend/dashboard/src/components/ui/dropdown-menu.tsx b/backend/dashboard/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000000..d72f8f613c --- /dev/null +++ b/backend/dashboard/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,273 @@ +"use client"; + +import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; +import { CheckIcon, ChevronRightIcon } from "lucide-react"; + +function DropdownMenu({ + ...props +}: React.ComponentProps) { + return ; +} + +function DropdownMenuPortal({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuContent({ + className, + align = "start", + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +function DropdownMenuGroup({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean; + variant?: "default" | "destructive"; +}) { + return ( + + ); +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + + + + + + {children} + + ); +} + +function DropdownMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuRadioItem({ + className, + children, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + + + + + + {children} + + ); +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + ); +} + +function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ); +} + +function DropdownMenuSub({ + ...props +}: React.ComponentProps) { + return ; +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + {children} + + + ); +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +}; diff --git a/backend/dashboard/src/components/ui/field.tsx b/backend/dashboard/src/components/ui/field.tsx new file mode 100644 index 0000000000..cbdd27b761 --- /dev/null +++ b/backend/dashboard/src/components/ui/field.tsx @@ -0,0 +1,236 @@ +import { type VariantProps, cva } from "class-variance-authority"; +import { useMemo } from "react"; + +import { Label } from "@/components/ui/label"; +import { Separator } from "@/components/ui/separator"; +import { cn } from "@/lib/utils"; + +function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) { + return ( +
[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3", + className, + )} + {...props} + /> + ); +} + +function FieldLegend({ + className, + variant = "legend", + ...props +}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) { + return ( + + ); +} + +function FieldGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +const fieldVariants = cva( + "group/field flex w-full gap-2 data-[invalid=true]:text-destructive", + { + variants: { + orientation: { + vertical: "flex-col *:w-full [&>.sr-only]:w-auto", + horizontal: + "flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", + responsive: + "flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", + }, + }, + defaultVariants: { + orientation: "vertical", + }, + }, +); + +function Field({ + className, + orientation = "vertical", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ); +} + +function FieldContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function FieldLabel({ + className, + ...props +}: React.ComponentProps) { + return ( +