diff --git a/api/config.ts b/api/config.ts index 79604a5..7cfec2a 100644 --- a/api/config.ts +++ b/api/config.ts @@ -1,4 +1,4 @@ -import { getEnvironmentVariable } from "@platform/config/environment.ts"; +import { getEnvironmentVariable } from "@platform/config"; import z from "zod"; export const config = { diff --git a/api/package.json b/api/package.json index 4aa209c..9cd602a 100644 --- a/api/package.json +++ b/api/package.json @@ -6,6 +6,7 @@ "dependencies": { "@modules/identity": "workspace:*", "@module/workspace": "workspace:*", - "zod": "4.1.11" + "@platform/config": "workspace:*", + "zod": "4.1.12" } } diff --git a/api/server.ts b/api/server.ts index 8c67b78..060ded3 100644 --- a/api/server.ts +++ b/api/server.ts @@ -1,5 +1,3 @@ -import identity from "@modules/identity/server.ts"; -import workspace from "@modules/workspace/server.ts"; import { logger } from "@platform/logger"; import { context } from "@platform/relay"; import { Api } from "@platform/server/api.ts"; @@ -26,7 +24,7 @@ await session.bootstrap(); // ### Modules -await workspace.bootstrap(); +// await workspace.bootstrap(); /* |-------------------------------------------------------------------------------- @@ -34,7 +32,9 @@ await workspace.bootstrap(); |-------------------------------------------------------------------------------- */ -const api = new Api([...identity.routes, ...workspace.routes]); +const api = new Api([ + /*...identity.routes, ...workspace.routes*/ +]); /* |-------------------------------------------------------------------------------- diff --git a/api/session.ts b/api/session.ts index 0c1f647..bba990d 100644 --- a/api/session.ts +++ b/api/session.ts @@ -1,5 +1,3 @@ -import { identity } from "@modules/iam/client.ts"; -import { getPrincipalSession } from "@modules/identity/server.ts"; import { context, UnauthorizedError } from "@platform/relay"; import { storage } from "@platform/storage"; diff --git a/apps/react/components.json b/apps/react/components.json new file mode 100644 index 0000000..c3a0a52 --- /dev/null +++ b/apps/react/components.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "src/index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "utils": "@/libraries/utils", + "ui": "@/components/ui", + "lib": "@/libraries", + "hooks": "@/hooks" + }, + "registries": {} +} diff --git a/apps/react/package.json b/apps/react/package.json index 256152c..396ec5d 100644 --- a/apps/react/package.json +++ b/apps/react/package.json @@ -10,17 +10,33 @@ "preview": "vite preview" }, "dependencies": { + "@module/account": "workspace:*", "@platform/relay": "workspace:*", "@platform/spec": "workspace:*", + "@radix-ui/react-avatar": "^1.1.11", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-scroll-area": "^1.2.10", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-tooltip": "^1.2.8", + "@tabler/icons-react": "3.35.0", "@tanstack/react-query": "5.89.0", "@tanstack/react-router": "1.131.47", "@valkyr/db": "npm:@jsr/valkyr__db@2.0.0", "@valkyr/event-emitter": "npm:@jsr/valkyr__event-emitter@1.0.1", + "@zitadel/react": "1.1.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", "fast-equals": "5.2.2", + "lucide-react": "^0.554.0", "react": "19.1.1", "react-dom": "19.1.1", + "tailwind-merge": "^3.4.0", "tailwindcss": "4.1.13", - "zod": "4.1.11" + "tailwindcss-animate": "^1.0.7", + "tw-animate-css": "1.4.0", + "zod": "4.1.12" }, "devDependencies": { "@eslint/js": "9.35.0", diff --git a/apps/react/src/components/Session.tsx b/apps/react/src/components/Session.tsx deleted file mode 100644 index efcf127..0000000 --- a/apps/react/src/components/Session.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { makeControllerView } from "../libraries/view.ts"; -import { SessionController } from "./session.controller.ts"; - -export const Session = makeControllerView(SessionController, ({ state: { error } }) => { - if (error !== undefined) { - return "Failed to fetch session"; - } - return
Session OK!
; -}); diff --git a/apps/react/src/components/app-sidebar.tsx b/apps/react/src/components/app-sidebar.tsx new file mode 100644 index 0000000..3952ec0 --- /dev/null +++ b/apps/react/src/components/app-sidebar.tsx @@ -0,0 +1,178 @@ +"use client"; + +import { + IconCamera, + IconChartBar, + IconDashboard, + IconDatabase, + IconFileAi, + IconFileDescription, + IconFileWord, + IconFolder, + IconHelp, + IconInnerShadowTop, + IconListDetails, + IconReport, + IconSearch, + IconSettings, + IconUsers, +} from "@tabler/icons-react"; +import type * as React from "react"; + +import { NavDocuments } from "@/components/nav-documents"; +import { NavMain } from "@/components/nav-main"; +import { NavSecondary } from "@/components/nav-secondary"; +import { NavUser } from "@/components/nav-user"; +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from "@/components/ui/sidebar"; + +const data = { + user: { + name: "shadcn", + email: "m@example.com", + avatar: "/avatars/shadcn.jpg", + }, + navMain: [ + { + title: "Dashboard", + url: "#", + icon: IconDashboard, + }, + { + title: "Lifecycle", + url: "#", + icon: IconListDetails, + }, + { + title: "Analytics", + url: "#", + icon: IconChartBar, + }, + { + title: "Projects", + url: "#", + icon: IconFolder, + }, + { + title: "Team", + url: "#", + icon: IconUsers, + }, + ], + navClouds: [ + { + title: "Capture", + icon: IconCamera, + isActive: true, + url: "#", + items: [ + { + title: "Active Proposals", + url: "#", + }, + { + title: "Archived", + url: "#", + }, + ], + }, + { + title: "Proposal", + icon: IconFileDescription, + url: "#", + items: [ + { + title: "Active Proposals", + url: "#", + }, + { + title: "Archived", + url: "#", + }, + ], + }, + { + title: "Prompts", + icon: IconFileAi, + url: "#", + items: [ + { + title: "Active Proposals", + url: "#", + }, + { + title: "Archived", + url: "#", + }, + ], + }, + ], + navSecondary: [ + { + title: "Settings", + url: "#", + icon: IconSettings, + }, + { + title: "Get Help", + url: "#", + icon: IconHelp, + }, + { + title: "Search", + url: "#", + icon: IconSearch, + }, + ], + documents: [ + { + name: "Data Library", + url: "#", + icon: IconDatabase, + }, + { + name: "Reports", + url: "#", + icon: IconReport, + }, + { + name: "Word Assistant", + url: "#", + icon: IconFileWord, + }, + ], +}; + +export function AppSidebar({ ...props }: React.ComponentProps) { + return ( + + + + + + + + Acme Inc. + + + + + + + + + + + + + + + ); +} diff --git a/apps/react/src/components/nav-documents.tsx b/apps/react/src/components/nav-documents.tsx new file mode 100644 index 0000000..972a543 --- /dev/null +++ b/apps/react/src/components/nav-documents.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { type Icon, IconDots, IconFolder, IconShare3, IconTrash } from "@tabler/icons-react"; + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + SidebarGroup, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuAction, + SidebarMenuButton, + SidebarMenuItem, + useSidebar, +} from "@/components/ui/sidebar"; + +export function NavDocuments({ + items, +}: { + items: { + name: string; + url: string; + icon: Icon; + }[]; +}) { + const { isMobile } = useSidebar(); + + return ( + + Documents + + {items.map((item) => ( + + + + + {item.name} + + + + + + + More + + + + + + Open + + + + Share + + + + + Delete + + + + + ))} + + + + More + + + + + ); +} diff --git a/apps/react/src/components/nav-main.tsx b/apps/react/src/components/nav-main.tsx new file mode 100644 index 0000000..ac0332f --- /dev/null +++ b/apps/react/src/components/nav-main.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { type Icon, IconCirclePlusFilled, IconMail } from "@tabler/icons-react"; + +import { Button } from "@/components/ui/button"; +import { + SidebarGroup, + SidebarGroupContent, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from "@/components/ui/sidebar"; + +export function NavMain({ + items, +}: { + items: { + title: string; + url: string; + icon?: Icon; + }[]; +}) { + return ( + + + + + + + Quick Create + + + + + + {items.map((item) => ( + + + {item.icon && } + {item.title} + + + ))} + + + + ); +} diff --git a/apps/react/src/components/nav-secondary.tsx b/apps/react/src/components/nav-secondary.tsx new file mode 100644 index 0000000..7690c2d --- /dev/null +++ b/apps/react/src/components/nav-secondary.tsx @@ -0,0 +1,42 @@ +"use client"; + +import type { Icon } from "@tabler/icons-react"; +import type * as React from "react"; + +import { + SidebarGroup, + SidebarGroupContent, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from "@/components/ui/sidebar"; + +export function NavSecondary({ + items, + ...props +}: { + items: { + title: string; + url: string; + icon: Icon; + }[]; +} & React.ComponentPropsWithoutRef) { + return ( + + + + {items.map((item) => ( + + + + + {item.title} + + + + ))} + + + + ); +} diff --git a/apps/react/src/components/nav-user.controller.ts b/apps/react/src/components/nav-user.controller.ts new file mode 100644 index 0000000..9735a71 --- /dev/null +++ b/apps/react/src/components/nav-user.controller.ts @@ -0,0 +1,51 @@ +import { Controller } from "../libraries/controller.ts"; +import { type User as ZitadelUser, zitadel } from "../services/zitadel.ts"; + +export class NavUserController extends Controller<{ + user?: User; +}> { + async onInit() { + return { + user: await this.#getAuthenticatedUser(), + }; + } + + async #getAuthenticatedUser(): Promise { + const user = await zitadel.userManager.getUser(); + if (user !== null) { + return getUserProfile(user); + } + } + + authorize() { + zitadel.authorize(); + } + + signout() { + zitadel.signout(); + } +} + +function getUserProfile({ profile }: ZitadelUser): User { + const user: User = { name: "Unknown", email: "unknown@acme.none", avatar: "" }; + if (profile.name) { + user.name = profile.name; + } else if (profile.given_name && profile.family_name) { + user.name = `${profile.given_name} ${profile.family_name}`; + } else if (profile.given_name) { + user.name = profile.given_name; + } + if (profile.email) { + user.email = profile.email; + } + if (profile.picture !== undefined) { + user.avatar = profile.picture; + } + return user; +} + +type User = { + name: string; + email: string; + avatar: string; +}; diff --git a/apps/react/src/components/nav-user.tsx b/apps/react/src/components/nav-user.tsx new file mode 100644 index 0000000..80fefc8 --- /dev/null +++ b/apps/react/src/components/nav-user.tsx @@ -0,0 +1,140 @@ +"use client"; + +import { IconCreditCard, IconDotsVertical, IconLogout, IconNotification, IconUserCircle } from "@tabler/icons-react"; + +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar } from "@/components/ui/sidebar"; +import { useController } from "@/libraries/controller.ts"; + +import { NavUserController } from "./nav-user.controller.ts"; + +export function NavUser() { + const [{ user }, loading, { authorize, signout }] = useController(NavUserController); + const { isMobile } = useSidebar(); + + console.log({authorize}) + + if (loading === true || user === undefined) { + return ( + + + + + + + + CN + +
+ Guest + guest@fixture.none +
+ +
+
+ + +
+ + + CN + +
+ Guest + guest@fixture.none +
+
+
+ + authorize()}> + + Sign in + +
+
+
+
+ ); + } + + return ( + + + + + + + + CN + +
+ {user.name} + {user.email} +
+ +
+
+ + +
+ + + CN + +
+ {user.name} + {user.email} +
+
+
+ + + + + Account + + + + Billing + + + + Notifications + + + + signout()}> + + Log out + +
+
+
+
+ ); +} diff --git a/apps/react/src/components/session.controller.ts b/apps/react/src/components/session.controller.ts deleted file mode 100644 index 1de21e9..0000000 --- a/apps/react/src/components/session.controller.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Controller } from "../libraries/controller.ts"; -import { api } from "../services/api.ts"; - -export class SessionController extends Controller<{ - error?: string; -}> { - async onInit() { - await this.getSessionCookie(); - } - - async getSessionCookie() { - const response = await api.auth.authenticate({ - body: { - type: "email", - email: "john.doe@fixture.none", - }, - }); - if ("error" in response) { - this.setState("error", undefined); - } - } -} diff --git a/apps/react/src/components/site-header.tsx b/apps/react/src/components/site-header.tsx new file mode 100644 index 0000000..c21931f --- /dev/null +++ b/apps/react/src/components/site-header.tsx @@ -0,0 +1,27 @@ +import { Button } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; +import { SidebarTrigger } from "@/components/ui/sidebar"; + +export function SiteHeader() { + return ( +
+
+ + +

Documents

+
+ +
+
+
+ ); +} diff --git a/apps/react/src/components/theme-provider.tsx b/apps/react/src/components/theme-provider.tsx new file mode 100644 index 0000000..4d676bd --- /dev/null +++ b/apps/react/src/components/theme-provider.tsx @@ -0,0 +1,67 @@ +import { createContext, useContext, useEffect, useState } from "react"; + +type Theme = "dark" | "light" | "system"; + +type ThemeProviderProps = { + children: React.ReactNode; + defaultTheme?: Theme; + storageKey?: string; +}; + +type ThemeProviderState = { + theme: Theme; + setTheme: (theme: Theme) => void; +}; + +const initialState: ThemeProviderState = { + theme: "system", + setTheme: () => null, +}; + +const ThemeProviderContext = createContext(initialState); + +export function ThemeProvider({ + children, + defaultTheme = "system", + storageKey = "vite-ui-theme", + ...props +}: ThemeProviderProps) { + const [theme, setTheme] = useState(() => (localStorage.getItem(storageKey) as Theme) || defaultTheme); + + useEffect(() => { + const root = window.document.documentElement; + + root.classList.remove("light", "dark"); + + if (theme === "system") { + const systemTheme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; + + root.classList.add(systemTheme); + return; + } + + root.classList.add(theme); + }, [theme]); + + const value = { + theme, + setTheme: (theme: Theme) => { + localStorage.setItem(storageKey, theme); + setTheme(theme); + }, + }; + + return ( + + {children} + + ); +} + +export const useTheme = () => { + const context = useContext(ThemeProviderContext); + + if (context === undefined) throw new Error("useTheme must be used within a ThemeProvider"); + + return context; +}; diff --git a/apps/react/src/components/ui/avatar.tsx b/apps/react/src/components/ui/avatar.tsx new file mode 100644 index 0000000..35db151 --- /dev/null +++ b/apps/react/src/components/ui/avatar.tsx @@ -0,0 +1,38 @@ +import * as AvatarPrimitive from "@radix-ui/react-avatar"; +import * as React from "react"; + +import { cn } from "../../libraries/utils.ts"; + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Avatar.displayName = AvatarPrimitive.Root.displayName; + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarImage.displayName = AvatarPrimitive.Image.displayName; + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; + +export { Avatar, AvatarImage, AvatarFallback }; diff --git a/apps/react/src/components/ui/button.tsx b/apps/react/src/components/ui/button.tsx new file mode 100644 index 0000000..f4f67f1 --- /dev/null +++ b/apps/react/src/components/ui/button.tsx @@ -0,0 +1,47 @@ +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; +import * as React from "react"; + +import { cn } from "../../libraries/utils.ts"; + +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/apps/react/src/components/ui/dropdown-menu.tsx b/apps/react/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..696a682 --- /dev/null +++ b/apps/react/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,180 @@ +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; +import { Check, ChevronRight, Circle } from "lucide-react"; +import * as React from "react"; + +import { cn } from "../../libraries/utils.ts"; + +const DropdownMenu = DropdownMenuPrimitive.Root; + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; + +const DropdownMenuGroup = DropdownMenuPrimitive.Group; + +const DropdownMenuPortal = DropdownMenuPrimitive.Portal; + +const DropdownMenuSub = DropdownMenuPrimitive.Sub; + +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; + +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)); +DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName; + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName; + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)); +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + svg]:size-4 [&>svg]:shrink-0", + inset && "pl-8", + className, + )} + {...props} + /> +)); +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName; + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; + +const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes) => { + return ; +}; +DropdownMenuShortcut.displayName = "DropdownMenuShortcut"; + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +}; diff --git a/apps/react/src/components/ui/input.tsx b/apps/react/src/components/ui/input.tsx new file mode 100644 index 0000000..22e9562 --- /dev/null +++ b/apps/react/src/components/ui/input.tsx @@ -0,0 +1,21 @@ +import type * as React from "react"; + +import { cn } from "@/libraries/utils"; + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ); +} + +export { Input }; diff --git a/apps/react/src/components/ui/scroll-area.tsx b/apps/react/src/components/ui/scroll-area.tsx new file mode 100644 index 0000000..cade2ee --- /dev/null +++ b/apps/react/src/components/ui/scroll-area.tsx @@ -0,0 +1,38 @@ +import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"; +import * as React from "react"; + +import { cn } from "@/libraries/utils"; + +const ScrollArea = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + {children} + + + +)); +ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName; + +const ScrollBar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, orientation = "vertical", ...props }, ref) => ( + + + +)); +ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName; + +export { ScrollArea, ScrollBar }; diff --git a/apps/react/src/components/ui/separator.tsx b/apps/react/src/components/ui/separator.tsx new file mode 100644 index 0000000..c1c3644 --- /dev/null +++ b/apps/react/src/components/ui/separator.tsx @@ -0,0 +1,26 @@ +import * as SeparatorPrimitive from "@radix-ui/react-separator"; +import type * as React from "react"; + +import { cn } from "@/libraries/utils"; + +function Separator({ + className, + orientation = "horizontal", + decorative = true, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { Separator }; diff --git a/apps/react/src/components/ui/sheet.tsx b/apps/react/src/components/ui/sheet.tsx new file mode 100644 index 0000000..f377113 --- /dev/null +++ b/apps/react/src/components/ui/sheet.tsx @@ -0,0 +1,103 @@ +"use client"; + +import * as SheetPrimitive from "@radix-ui/react-dialog"; +import { XIcon } from "lucide-react"; +import type * as React from "react"; + +import { cn } from "@/libraries/utils"; + +function Sheet({ ...props }: React.ComponentProps) { + return ; +} + +function SheetTrigger({ ...props }: React.ComponentProps) { + return ; +} + +function SheetClose({ ...props }: React.ComponentProps) { + return ; +} + +function SheetPortal({ ...props }: React.ComponentProps) { + return ; +} + +function SheetOverlay({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function SheetContent({ + className, + children, + side = "right", + ...props +}: React.ComponentProps & { + side?: "top" | "right" | "bottom" | "left"; +}) { + return ( + + + + {children} + + + Close + + + + ); +} + +function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { + return
; +} + +function SheetFooter({ className, ...props }: React.ComponentProps<"div">) { + return
; +} + +function SheetTitle({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function SheetDescription({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +export { Sheet, SheetTrigger, SheetClose, SheetContent, SheetHeader, SheetFooter, SheetTitle, SheetDescription }; diff --git a/apps/react/src/components/ui/sidebar.tsx b/apps/react/src/components/ui/sidebar.tsx new file mode 100644 index 0000000..ef84fe8 --- /dev/null +++ b/apps/react/src/components/ui/sidebar.tsx @@ -0,0 +1,643 @@ +"use client"; + +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; +import { PanelLeft } from "lucide-react"; +import * as React from "react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Separator } from "@/components/ui/separator"; +import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { useIsMobile } from "@/hooks/use-mobile"; +import { cn } from "@/libraries/utils"; + +const SIDEBAR_COOKIE_NAME = "sidebar_state"; +const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; +const SIDEBAR_WIDTH = "16rem"; +const SIDEBAR_WIDTH_MOBILE = "18rem"; +const SIDEBAR_WIDTH_ICON = "3rem"; +const SIDEBAR_KEYBOARD_SHORTCUT = "b"; + +type SidebarContextProps = { + state: "expanded" | "collapsed"; + open: boolean; + setOpen: (open: boolean) => void; + openMobile: boolean; + setOpenMobile: (open: boolean) => void; + isMobile: boolean; + toggleSidebar: () => void; +}; + +const SidebarContext = React.createContext(null); + +function useSidebar() { + const context = React.useContext(SidebarContext); + if (!context) { + throw new Error("useSidebar must be used within a SidebarProvider."); + } + + return context; +} + +const SidebarProvider = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & { + defaultOpen?: boolean; + open?: boolean; + onOpenChange?: (open: boolean) => void; + } +>(({ defaultOpen = true, open: openProp, onOpenChange: setOpenProp, className, style, children, ...props }, ref) => { + const isMobile = useIsMobile(); + const [openMobile, setOpenMobile] = React.useState(false); + + // This is the internal state of the sidebar. + // We use openProp and setOpenProp for control from outside the component. + const [_open, _setOpen] = React.useState(defaultOpen); + const open = openProp ?? _open; + const setOpen = React.useCallback( + (value: boolean | ((value: boolean) => boolean)) => { + const openState = typeof value === "function" ? value(open) : value; + if (setOpenProp) { + setOpenProp(openState); + } else { + _setOpen(openState); + } + + // This sets the cookie to keep the sidebar state. + document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`; + }, + [setOpenProp, open], + ); + + // Helper to toggle the sidebar. + const toggleSidebar = React.useCallback(() => { + return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open); + }, [isMobile, setOpen, setOpenMobile]); + + // Adds a keyboard shortcut to toggle the sidebar. + React.useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) { + event.preventDefault(); + toggleSidebar(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [toggleSidebar]); + + // We add a state so that we can do data-state="expanded" or "collapsed". + // This makes it easier to style the sidebar with Tailwind classes. + const state = open ? "expanded" : "collapsed"; + + const contextValue = React.useMemo( + () => ({ + state, + open, + setOpen, + isMobile, + openMobile, + setOpenMobile, + toggleSidebar, + }), + [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar], + ); + + return ( + + +
+ {children} +
+
+
+ ); +}); +SidebarProvider.displayName = "SidebarProvider"; + +const Sidebar = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & { + side?: "left" | "right"; + variant?: "sidebar" | "floating" | "inset"; + collapsible?: "offcanvas" | "icon" | "none"; + } +>(({ side = "left", variant = "sidebar", collapsible = "offcanvas", className, children, ...props }, ref) => { + const { isMobile, state, openMobile, setOpenMobile } = useSidebar(); + + if (collapsible === "none") { + return ( +
+ {children} +
+ ); + } + + if (isMobile) { + return ( + + + + Sidebar + Displays the mobile sidebar. + +
{children}
+
+
+ ); + } + + return ( +
+ {/* This is what handles the sidebar gap on desktop */} +
+ +
+ ); +}); +Sidebar.displayName = "Sidebar"; + +const SidebarTrigger = React.forwardRef, React.ComponentProps>( + ({ className, onClick, ...props }, ref) => { + const { toggleSidebar } = useSidebar(); + + return ( + + ); + }, +); +SidebarTrigger.displayName = "SidebarTrigger"; + +const SidebarRail = React.forwardRef>( + ({ className, ...props }, ref) => { + const { toggleSidebar } = useSidebar(); + + return ( + + * ); + * } + * ``` + */ +export function useController Controller>( + ControllerClass: TController, + props?: InstanceType["props"], +): [InstanceType["state"], boolean, ControllerActions>] { + const [state, setState] = useState({}); + const [loading, setLoading] = useState(true); + + const controllerRef = useRef | null>(null); + const actionsRef = useRef> | null>(null); + const propsRef = useRef(props); + + // Resolve only once after creation + useMemo(() => { + const instance = (ControllerClass as any).make(setState, setLoading); + controllerRef.current = instance; + actionsRef.current = instance.toActions(); + + instance.$resolve(props || {}); + + return () => { + instance.$destroy(); + }; + }, [controllerRef]); + + // Resolve on props change + useEffect(() => { + if (propsRef.current !== props) { + propsRef.current = props; + controllerRef.current?.$resolve(props || {}); + } + }, [props]); + + return [state, loading, actionsRef.current!]; +} + /* |-------------------------------------------------------------------------------- | Types |-------------------------------------------------------------------------------- */ -type Query = Where & SubscriptionOptions; - -type QuerySingle = Where & SubscribeToSingle; - -type QueryMany = Where & SubscribeToMany; - -type Where = { - where?: Record; +type ControllerActions = { + [K in keyof T]: T[K] extends (...args: any[]) => any + ? K extends `$${string}` | `_${string}` | `#${string}` | "constructor" + ? never + : T[K] + : never; }; - -type CollectionSchema = TCollection extends Collection ? TSchema : never; diff --git a/apps/react/src/libraries/debounce.ts b/apps/react/src/libraries/debounce.ts deleted file mode 100644 index 0aa2893..0000000 --- a/apps/react/src/libraries/debounce.ts +++ /dev/null @@ -1,14 +0,0 @@ -export class Debounce { - #timeout?: number; - - run(fn: (...args: any[]) => void, ms: number): void { - this.#clear(); - this.#timeout = setTimeout(fn, ms); - } - - #clear() { - if (this.#timeout !== undefined) { - clearTimeout(this.#timeout); - } - } -} diff --git a/apps/react/src/libraries/refs.ts b/apps/react/src/libraries/refs.ts deleted file mode 100644 index 5b5b9d1..0000000 --- a/apps/react/src/libraries/refs.ts +++ /dev/null @@ -1,50 +0,0 @@ -const refs = new Map(); - -export class ControllerRefs { - #refs = new Map(); - - #forwarded: string[] = []; - - set(name: string) { - return (element: HTMLElement | null) => { - if (element !== null) { - refs.set(name, element); - } - }; - } - - forward(name: string) { - return (element: HTMLElement | null) => { - if (element !== null) { - refs.set(name, element); - this.#forwarded.push(name); - } - }; - } - - get(name: string) { - const element = this.#refs.get(name) ?? refs.get(name); - if (element === undefined) { - throw new Error(`Reference Exception: ${name} is not defined.`); - } - return element; - } - - async on(name: string, count = 0): Promise { - if (count > 20) { - return undefined; - } - const element = this.#refs.get(name) ?? refs.get(name); - if (element === undefined) { - await new Promise((resolve) => setTimeout(resolve, 50)); - return this.on(name, count + 1); - } - return element; - } - - destroy() { - this.#forwarded.forEach((name) => { - refs.delete(name); - }); - } -} diff --git a/apps/react/src/libraries/types.ts b/apps/react/src/libraries/types.ts deleted file mode 100644 index 9ba5e7d..0000000 --- a/apps/react/src/libraries/types.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type React from "react"; -import type { FunctionComponent } from "react"; - -import type { ControllerRefs } from "./refs.ts"; - -export type ReactComponent = FunctionComponent<{ - props: TProps; - state: InstanceType["state"]; - actions: Omit, ReservedPropertyMembers>; - refs: ControllerRefs; - component?: React.FC; -}>; - -export type ControllerClass = { - new (state: any, pushState: any): any; - make(component: ReactComponent, pushState: any): any; -}; - -export type ReservedPropertyMembers = "state" | "pushState" | "init" | "destroy" | "setNext" | "setState" | "toActions"; - -export type Unknown = Record; - -export type Empty = Record; diff --git a/apps/react/src/libraries/utils.ts b/apps/react/src/libraries/utils.ts new file mode 100644 index 0000000..365058c --- /dev/null +++ b/apps/react/src/libraries/utils.ts @@ -0,0 +1,6 @@ +import { type ClassValue, clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/apps/react/src/libraries/view.ts b/apps/react/src/libraries/view.ts deleted file mode 100644 index 5d86eae..0000000 --- a/apps/react/src/libraries/view.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { deepEqual } from "fast-equals"; -import React, { createElement, type FunctionComponent, memo, type PropsWithChildren, useEffect, useState } from "react"; - -import type { ControllerClass, ReactComponent, Unknown } from "./types.ts"; - -/* - |-------------------------------------------------------------------------------- - | Options - |-------------------------------------------------------------------------------- - */ - -const options: Partial> = { - memoize: defaultMemoizeHandler, -}; - -/* - |-------------------------------------------------------------------------------- - | Factory - |-------------------------------------------------------------------------------- - */ - -export function makeControllerView( - controller: TController, - component: ReactComponent, - options?: Partial>, -): FunctionComponent { - const memoize = getMemoizeHandler(options?.memoize); - const render = { - loading: getLoadingComponent(options), - error: getErrorComponent(options), - }; - - const container: FunctionComponent> = (props: any) => { - const { error, view } = useView(controller, component, props); - if (view === undefined) { - return render.loading(props); - } - if (error !== undefined) { - return render.error({ ...props, error }); - } - return view; - }; - - container.displayName = component.displayName = options?.name ?? `${controller.name}View`; - - // ### Memoize - // By default run component through react memoization using stringify - // matching to determine changes to props. - - if (memoize !== false) { - return memo(container, memoize); - } - - return container; -} - -/* - |-------------------------------------------------------------------------------- - | Hooks - |-------------------------------------------------------------------------------- - */ - -function useView( - instance: InstanceType | undefined, - component: ReactComponent, - props: any, -) { - const [view, setView] = useState(); - - const error = useController(instance, component, props, setView); - - return { error, view }; -} - -function useController(controller: ControllerClass, component: any, props: any, setView: any) { - const [instance, setInstance] = useState | undefined>(undefined); - const error = useProps(instance, props); - - useEffect(() => { - const instance = controller.make(component, setView); - setInstance(instance); - return () => { - instance.$destroy(); - }; - }, [component, controller, setView]); - - return error; -} - -function useProps(controller: InstanceType | undefined, props: any) { - const [error, setError] = useState(); - - useEffect(() => { - if (controller === undefined) { - return; - } - let isMounted = true; - controller.$resolve(props).catch((error: Error) => { - if (isMounted === true) { - setError(error); - } - }); - return () => { - isMounted = false; - }; - }, [controller, props]); - - return error; -} - -/* - |-------------------------------------------------------------------------------- - | Components - |-------------------------------------------------------------------------------- - */ - -export function setLoadingComponent(component: React.FC) { - options.loading = component; -} - -function getLoadingComponent({ loading }: Partial> = {}) { - const component = loading ?? options.loading; - if (component === undefined) { - return () => null; - } - return (props: TProps) => createElement(component, props); -} - -export function setErrorComponent(component: React.FC) { - options.error = component; -} - -function getErrorComponent({ error }: Partial> = {}) { - const component = error ?? options.loading; - if (component === undefined) { - return () => null; - } - return (props: TProps) => createElement(component, props); -} - -/* - |-------------------------------------------------------------------------------- - | Memoize - |-------------------------------------------------------------------------------- - */ - -export function setMemoizeHandler(value: boolean | Memoize) { - if (typeof value === "function") { - options.memoize = value; - } else if (value === false) { - options.memoize = false; - } else { - options.memoize = defaultMemoizeHandler; - } -} - -function getMemoizeHandler(memoize?: ViewOptions["memoize"]): false | Memoize | undefined { - if (typeof memoize === "function") { - return memoize; - } - if (memoize !== false) { - return options.memoize; - } - return false; -} - -/* - |-------------------------------------------------------------------------------- - | Defaults - |-------------------------------------------------------------------------------- - */ - -function defaultMemoizeHandler(prev: any, next: any): boolean { - if (prev.children !== undefined && next.children !== undefined) { - if (prev.children.type.type.displayName !== next.children.type.type.displayName) { - return false; - } - } - return deepEqual(prev, next); -} - -/* - |-------------------------------------------------------------------------------- - | Types - |-------------------------------------------------------------------------------- - */ - -export type ViewOptions = { - name?: string; - loading: React.FC; - error: React.FC; - memoize: false | Memoize; -}; - -type Memoize = (prevProps: Readonly, nextProps: Readonly) => boolean; - -type Readonly = { - readonly [P in keyof T]: T[P]; -}; diff --git a/apps/react/src/main.tsx b/apps/react/src/main.tsx index 542b1fc..9f22d46 100644 --- a/apps/react/src/main.tsx +++ b/apps/react/src/main.tsx @@ -4,6 +4,7 @@ import { createRouter, RouterProvider } from "@tanstack/react-router"; import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; +import { ThemeProvider } from "./components/theme-provider.tsx"; import { routeTree } from "./routes.tsx"; const router = createRouter({ routeTree }); @@ -14,8 +15,15 @@ declare module "@tanstack/react-router" { } } -createRoot(document.getElementById("root")!).render( +const rootElement = document.getElementById("root"); +if (rootElement === null) { + throw new Error("Failed to retrieve root element"); +} + +createRoot(rootElement).render( - + + + , ); diff --git a/apps/react/src/routes.tsx b/apps/react/src/routes.tsx index a9d2065..394bb73 100644 --- a/apps/react/src/routes.tsx +++ b/apps/react/src/routes.tsx @@ -1,36 +1,30 @@ -import { createRootRoute, createRoute, Outlet } from "@tanstack/react-router"; -import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"; +import { createRootRoute, createRoute } from "@tanstack/react-router"; -import { CreateAccountView } from "./views/account/create.view.tsx"; -import { TodosView } from "./views/todo/todos.view.tsx"; +import { AppView } from "./views/app.view.tsx"; +import { CallbackView } from "./views/auth/callback.view.tsx"; +import { DashboardView } from "./views/dashboard/dashboard.view.tsx"; -const rootRoute = createRootRoute({ - component: () => ( - <> - - - - ), +const root = createRootRoute(); + +const callback = createRoute({ + getParentRoute: () => root, + path: "/callback", + component: CallbackView, }); -const homeRoute = createRoute({ - getParentRoute: () => rootRoute, +const app = createRoute({ + id: "app", + getParentRoute: () => root, + component: AppView, +}); + +const dashboard = createRoute({ + getParentRoute: () => app, path: "/", - component: function Index() { - return

Welcome Home!

; - }, + component: DashboardView, }); -const createAccountRoute = createRoute({ - getParentRoute: () => rootRoute, - path: "/accounts", - component: CreateAccountView, -}); +root.addChildren([app, callback]); +app.addChildren([dashboard]); -const todosRoute = createRoute({ - getParentRoute: () => rootRoute, - path: "/todos", - component: TodosView, -}); - -export const routeTree = rootRoute.addChildren([homeRoute, createAccountRoute, todosRoute]); +export const routeTree = root; diff --git a/apps/react/src/services/api.ts b/apps/react/src/services/api.ts index 83ff98d..29060e7 100644 --- a/apps/react/src/services/api.ts +++ b/apps/react/src/services/api.ts @@ -1,3 +1,4 @@ +import { account } from "@module/account/client"; import { makeClient } from "@platform/relay"; import { HttpAdapter } from "../adapters/http.ts"; @@ -9,7 +10,6 @@ export const api = makeClient( }), }, { - account: (await import("@platform/spec/account/routes.ts")).routes, - auth: (await import("@platform/spec/auth/routes.ts")).routes, + account, }, ); diff --git a/apps/react/src/services/zitadel.ts b/apps/react/src/services/zitadel.ts new file mode 100644 index 0000000..50abd02 --- /dev/null +++ b/apps/react/src/services/zitadel.ts @@ -0,0 +1,14 @@ +import { createZitadelAuth, type ZitadelConfig } from "@zitadel/react"; + +const config: ZitadelConfig = { + authority: "https://auth.valkyrjs.com", + client_id: "347982179092987909", + redirect_uri: "http://localhost:5173/callback", + post_logout_redirect_uri: "http://localhost:5173", + response_type: "code", + scope: "openid profile email", +}; + +export const zitadel = createZitadelAuth(config); + +export type User = NonNullable>>; diff --git a/apps/react/src/theme.css b/apps/react/src/theme.css new file mode 100644 index 0000000..fd549d6 --- /dev/null +++ b/apps/react/src/theme.css @@ -0,0 +1,105 @@ +body { + @apply overscroll-none bg-transparent; +} + +:root { + --font-sans: var(--font-inter); + --header-height: calc(var(--spacing) * 12 + 1px); +} + +.theme-scaled { + @media (min-width: 1024px) { + --radius: 0.6rem; + --text-lg: 1.05rem; + --text-base: 0.85rem; + --text-sm: 0.8rem; + --spacing: 0.222222rem; + } + + [data-slot="card"] { + --spacing: 0.16rem; + } + + [data-slot="select-trigger"], + [data-slot="toggle-group-item"] { + --spacing: 0.222222rem; + } +} + +.theme-default, +.theme-default-scaled { + --primary: var(--color-neutral-600); + --primary-foreground: var(--color-neutral-50); + + @variant dark { + --primary: var(--color-neutral-500); + --primary-foreground: var(--color-neutral-50); + } +} + +.theme-blue, +.theme-blue-scaled { + --primary: var(--color-blue-600); + --primary-foreground: var(--color-blue-50); + + @variant dark { + --primary: var(--color-blue-500); + --primary-foreground: var(--color-blue-50); + } +} + +.theme-green, +.theme-green-scaled { + --primary: var(--color-lime-600); + --primary-foreground: var(--color-lime-50); + + @variant dark { + --primary: var(--color-lime-600); + --primary-foreground: var(--color-lime-50); + } +} + +.theme-amber, +.theme-amber-scaled { + --primary: var(--color-amber-600); + --primary-foreground: var(--color-amber-50); + + @variant dark { + --primary: var(--color-amber-500); + --primary-foreground: var(--color-amber-50); + } +} + +.theme-mono, +.theme-mono-scaled { + --font-sans: var(--font-mono); + --primary: var(--color-neutral-600); + --primary-foreground: var(--color-neutral-50); + + @variant dark { + --primary: var(--color-neutral-500); + --primary-foreground: var(--color-neutral-50); + } + + .rounded-xs, + .rounded-sm, + .rounded-md, + .rounded-lg, + .rounded-xl { + @apply !rounded-none; + border-radius: 0; + } + + .shadow-xs, + .shadow-sm, + .shadow-md, + .shadow-lg, + .shadow-xl { + @apply !shadow-none; + } + + [data-slot="toggle-group"], + [data-slot="toggle-group-item"] { + @apply !rounded-none !shadow-none; + } +} diff --git a/apps/react/src/views/account/create.controller.ts b/apps/react/src/views/account/create.controller.ts deleted file mode 100644 index 0f082b4..0000000 --- a/apps/react/src/views/account/create.controller.ts +++ /dev/null @@ -1,36 +0,0 @@ -import z from "zod"; - -import { Controller } from "../../libraries/controller.ts"; -import { Form } from "../../libraries/form.ts"; -import { api } from "../../services/api.ts"; - -const inputs = { - givenName: z.string(), - familyName: z.string(), - email: z.string(), -}; - -export class CreateController extends Controller<{ - form: Form; -}> { - async onInit() { - return { - form: new Form(inputs).onSubmit(async ({ givenName, familyName, email }) => { - const response = await api.account.create({ - body: { - name: { - given: givenName, - family: familyName, - }, - email, - }, - }); - if ("error" in response) { - console.log(response.error); - } else { - console.log(response.data); - } - }), - }; - } -} diff --git a/apps/react/src/views/account/create.view.tsx b/apps/react/src/views/account/create.view.tsx deleted file mode 100644 index fcae7ea..0000000 --- a/apps/react/src/views/account/create.view.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { makeControllerView } from "../../libraries/view.ts"; -import { CreateController } from "./create.controller.ts"; - -export const CreateAccountView = makeControllerView(CreateController, ({ state: { form } }) => { - return ( -
- - - - -
- ); -}); diff --git a/apps/react/src/views/app.controller.ts b/apps/react/src/views/app.controller.ts new file mode 100644 index 0000000..b5c1fc7 --- /dev/null +++ b/apps/react/src/views/app.controller.ts @@ -0,0 +1,25 @@ +import { Controller } from "../libraries/controller.ts"; +import { zitadel } from "../services/zitadel.ts"; + +export class AppController extends Controller<{ + authenticated: boolean; +}> { + async onInit() { + return { + authenticated: await this.#getAuthenticatedState(), + }; + } + + async #getAuthenticatedState(): Promise { + const user = await zitadel.userManager.getUser(); + if (user === null) { + zitadel.authorize(); + return false; + } + return true; + } + + signout() { + zitadel.signout(); + } +} diff --git a/apps/react/src/views/app.view.tsx b/apps/react/src/views/app.view.tsx new file mode 100644 index 0000000..8b83ee8 --- /dev/null +++ b/apps/react/src/views/app.view.tsx @@ -0,0 +1,40 @@ +import { Outlet } from "@tanstack/react-router"; + +import { AppSidebar } from "@/components/app-sidebar.tsx"; +import { SiteHeader } from "@/components/site-header.tsx"; +import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar.tsx"; +import { useController } from "@/libraries/controller.ts"; + +import { AppController } from "./app.controller.ts"; + +export function AppView() { + const [{ authenticated }, loading] = useController(AppController); + if (loading === true) { + return
Loading ...
; + } + if (authenticated === false) { + return
Unauthenticated
; + } + return ( + + + + +
+
+
+ +
+
+
+
+
+ ); +} diff --git a/apps/react/src/views/auth/callback.view.tsx b/apps/react/src/views/auth/callback.view.tsx new file mode 100644 index 0000000..2f1bec9 --- /dev/null +++ b/apps/react/src/views/auth/callback.view.tsx @@ -0,0 +1,26 @@ +import { useNavigate } from "@tanstack/react-router"; +import { useEffect } from "react"; + +import { zitadel } from "../../services/zitadel.ts"; + +export function CallbackView() { + const navigate = useNavigate(); + useEffect(() => { + async function handleCallback() { + try { + const user = await zitadel.userManager.signinRedirectCallback(); + if (user) { + navigate({ to: "/", replace: true }); + } else { + navigate({ to: "/", replace: true }); + } + } catch (error) { + console.error("Callback error", error); + navigate({ to: "/", replace: true }); + } + } + handleCallback(); + }, [navigate]); + + return null; +} diff --git a/apps/react/src/views/auth/login.controller.ts b/apps/react/src/views/auth/login.controller.ts new file mode 100644 index 0000000..b1d0a55 --- /dev/null +++ b/apps/react/src/views/auth/login.controller.ts @@ -0,0 +1,29 @@ +import { Controller } from "../../libraries/controller.ts"; +import { type User, zitadel } from "../../services/zitadel.ts"; + +export class LoginController extends Controller<{ + user?: User; +}> { + async onInit() { + return { + user: await this.#getAuthenticationState(), + }; + } + + async #getAuthenticationState(): Promise { + return zitadel.userManager.getUser().then((user) => { + if (user === null) { + return undefined; + } + return user; + }); + } + + login() { + zitadel.authorize(); + } + + logout() { + zitadel.signout(); + } +} diff --git a/apps/react/src/views/auth/login.view.tsx b/apps/react/src/views/auth/login.view.tsx new file mode 100644 index 0000000..10c3d21 --- /dev/null +++ b/apps/react/src/views/auth/login.view.tsx @@ -0,0 +1,14 @@ +import { useController } from "../../libraries/controller.ts"; +import { LoginController } from "./login.controller.ts"; + +export function LoginView() { + const [{ user }, { login, logout }] = useController(LoginController); + return ( +
+ + {user !== undefined ?
{JSON.stringify(user, null, 2)}
: null} +
+ ); +} diff --git a/apps/react/src/views/dashboard/dashboard.view.tsx b/apps/react/src/views/dashboard/dashboard.view.tsx new file mode 100644 index 0000000..6d0cf26 --- /dev/null +++ b/apps/react/src/views/dashboard/dashboard.view.tsx @@ -0,0 +1,3 @@ +export function DashboardView() { + return
Dashboard
; +} diff --git a/apps/react/src/views/todo/todos.controller.ts b/apps/react/src/views/todo/todos.controller.ts deleted file mode 100644 index 0d67666..0000000 --- a/apps/react/src/views/todo/todos.controller.ts +++ /dev/null @@ -1,28 +0,0 @@ -import z from "zod"; - -import { Controller } from "../../libraries/controller.ts"; -import { Form } from "../../libraries/form.ts"; -import { db } from "../../stores/database.ts"; -import type { Todo } from "../../stores/todo.ts"; - -const inputs = { - name: z.string(), -}; - -export class TodosController extends Controller<{ - form: Form; - todos: Todo[]; -}> { - override async onInit() { - return { - form: new Form(inputs).onSubmit(async ({ name }) => { - db.collection("todos").insertOne({ name, items: [] }); - }), - todos: await this.query(db.collection("todos"), { limit: 10 }, "todos"), - }; - } - - remove(id: string) { - db.collection("todos").remove({ id }); - } -} diff --git a/apps/react/src/views/todo/todos.view.tsx b/apps/react/src/views/todo/todos.view.tsx deleted file mode 100644 index 9014a7b..0000000 --- a/apps/react/src/views/todo/todos.view.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { Link } from "@tanstack/react-router"; - -import { makeControllerView } from "../../libraries/view.ts"; -import { TodosController } from "./todos.controller.ts"; - -export const TodosView = makeControllerView( - TodosController, - ({ state: { form, todos }, actions: { remove } }) => { - return ( -
-
- {/* Heading */} -
-

Todo Lists

-

Create and manage your collections of tasks

-
- - {/* Create form */} -
- - -
- - {/* Todo list output */} -
-

Your Lists

- {todos?.length > 0 ? ( -
    - {todos.map((todo) => ( -
  • - {/* List name */} - {todo.name} - - {/* Actions */} -
    - - Open - - -
    -
  • - ))} -
- ) : ( -

No todo lists yet. Create one above!

- )} -
-
-
- ); - }, -); diff --git a/apps/react/tsconfig.app.json b/apps/react/tsconfig.app.json index 126d126..b02fee3 100644 --- a/apps/react/tsconfig.app.json +++ b/apps/react/tsconfig.app.json @@ -21,7 +21,14 @@ "noUnusedParameters": true, // "erasableSyntaxOnly": true, "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true + "noUncheckedSideEffectImports": true, + + "baseUrl": ".", + "paths": { + "@/*": [ + "./src/*" + ] + } }, "include": ["src"] } diff --git a/apps/react/tsconfig.json b/apps/react/tsconfig.json index d32ff68..2b78387 100644 --- a/apps/react/tsconfig.json +++ b/apps/react/tsconfig.json @@ -1,4 +1,10 @@ { "files": [], - "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }] + "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }], + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + } } diff --git a/apps/react/vite.config.ts b/apps/react/vite.config.ts index 09a88e3..366f799 100644 --- a/apps/react/vite.config.ts +++ b/apps/react/vite.config.ts @@ -1,9 +1,15 @@ +import path from "node:path" import tailwindcss from "@tailwindcss/vite"; import react from "@vitejs/plugin-react"; import { defineConfig } from "vite"; export default defineConfig({ plugins: [react(), tailwindcss()], + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, server: { proxy: { "/api/v1": { diff --git a/deno.json b/deno.json index cbd7970..5d147e6 100644 --- a/deno.json +++ b/deno.json @@ -4,8 +4,8 @@ "workspace": [ "api", "apps/react", - "modules/iam", - "modules/workspace", + "modules/account", + "modules/tenant", "platform/cerbos", "platform/config", "platform/database", @@ -19,16 +19,16 @@ "platform/vault" ], "tasks": { - "start:api": { + "api": { "command": "cd ./api && deno run start", "description": "Start api server instance." }, - "start:react": { + "react": { "command": "cd ./apps/react && deno run dev", "description": "Start react application instance." }, "check": { - "command": "deno run -A npm:@biomejs/biome check --write ./api ./modules ./platform", + "command": "deno run -A npm:@biomejs/biome check --write ./api ./apps/react/src ./modules ./platform", "description": "Format, lint, and organize imports of the entire project." }, "test": { diff --git a/deno.lock b/deno.lock index 0f53e2b..769b4a3 100644 --- a/deno.lock +++ b/deno.lock @@ -3,9 +3,7 @@ "specifiers": { "npm:@biomejs/biome@*": "2.2.4", "npm:@biomejs/biome@2.2.4": "2.2.4", - "npm:@cerbos/core@0.24.1": "0.24.1", "npm:@cerbos/core@0.25.1": "0.25.1_@bufbuild+protobuf@2.10.1", - "npm:@cerbos/http@0.23.1": "0.23.1", "npm:@cerbos/http@0.23.3": "0.23.3_@bufbuild+protobuf@2.10.1_@cerbos+api@0.2.0", "npm:@eslint/js@9.35.0": "9.35.0", "npm:@jsr/std__assert@1.0.14": "1.0.14", @@ -14,8 +12,15 @@ "npm:@jsr/valkyr__db@2.0.0": "2.0.0", "npm:@jsr/valkyr__event-emitter@1.0.1": "1.0.1", "npm:@jsr/valkyr__event-store@2.0.1": "2.0.1", - "npm:@jsr/valkyr__inverse@1.0.1": "1.0.1", "npm:@jsr/valkyr__json-rpc@1.1.0": "1.1.0", + "npm:@radix-ui/react-avatar@^1.1.11": "1.1.11_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1", + "npm:@radix-ui/react-dialog@^1.1.15": "1.1.15_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1", + "npm:@radix-ui/react-dropdown-menu@^2.1.16": "2.1.16_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1", + "npm:@radix-ui/react-scroll-area@^1.2.10": "1.2.10_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1", + "npm:@radix-ui/react-separator@^1.1.8": "1.1.8_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1", + "npm:@radix-ui/react-slot@^1.2.4": "1.2.4_@types+react@19.1.13_react@19.1.1", + "npm:@radix-ui/react-tooltip@^1.2.8": "1.2.8_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1", + "npm:@tabler/icons-react@3.35.0": "3.35.0_react@19.1.1", "npm:@tailwindcss/vite@4.1.13": "4.1.13_vite@7.1.6__@types+node@24.2.0__picomatch@4.0.3_@types+node@24.2.0", "npm:@tanstack/react-query@5.89.0": "5.89.0_react@19.1.1", "npm:@tanstack/react-router-devtools@1.131.47": "1.131.47_@tanstack+react-router@1.131.47__react@19.1.1__react-dom@19.1.1___react@19.1.1_react@19.1.1_react-dom@19.1.1__react@19.1.1", @@ -24,24 +29,29 @@ "npm:@types/react-dom@19.1.9": "19.1.9_@types+react@19.1.13", "npm:@types/react@19.1.13": "19.1.13", "npm:@vitejs/plugin-react@4.7.0": "4.7.0_vite@7.1.6__@types+node@24.2.0__picomatch@4.0.3_@babel+core@7.28.4_@types+node@24.2.0", - "npm:better-auth@1.3.16": "1.3.16_react@19.1.1_react-dom@19.1.1__react@19.1.1", - "npm:cookie@1.0.2": "1.0.2", + "npm:@zitadel/react@1.1.0": "1.1.0", + "npm:class-variance-authority@~0.7.1": "0.7.1", + "npm:clsx@^2.1.1": "2.1.1", "npm:eslint-plugin-react-hooks@5.2.0": "5.2.0_eslint@9.35.0", "npm:eslint-plugin-react-refresh@0.4.20": "0.4.20_eslint@9.35.0", "npm:eslint@9.35.0": "9.35.0", "npm:fast-equals@5.2.2": "5.2.2", "npm:globals@16.4.0": "16.4.0", "npm:jose@6.1.0": "6.1.0", - "npm:mongodb@6.20.0": "6.20.0", + "npm:lucide-react@0.554": "0.554.0_react@19.1.1", "npm:nanoid@5.1.5": "5.1.5", "npm:path-to-regexp@8": "8.3.0", + "npm:postgres@3.4.7": "3.4.7", "npm:react-dom@19.1.1": "19.1.1_react@19.1.1", "npm:react@19.1.1": "19.1.1", + "npm:tailwind-merge@^3.4.0": "3.4.0", + "npm:tailwindcss-animate@^1.0.7": "1.0.7_tailwindcss@4.1.13", "npm:tailwindcss@4.1.13": "4.1.13", + "npm:tw-animate-css@1.4.0": "1.4.0", "npm:typescript-eslint@8.44.0": "8.44.0_eslint@9.35.0_typescript@5.9.2_@typescript-eslint+parser@8.44.0__eslint@9.35.0__typescript@5.9.2", "npm:typescript@5.9.2": "5.9.2", "npm:vite@7.1.6": "7.1.6_@types+node@24.2.0_picomatch@4.0.3", - "npm:zod@4.1.11": "4.1.11" + "npm:zod@4.1.12": "4.1.12" }, "npm": { "@babel/code-frame@7.27.1": { @@ -181,12 +191,6 @@ "@babel/helper-validator-identifier" ] }, - "@better-auth/utils@0.3.0": { - "integrity": "sha512-W+Adw6ZA6mgvnSnhOki270rwJ42t4XzSK6YWGF//BbVXL6SwCLWfyzBc1lN2m/4RM28KubdBKQ4X5VMoLRNPQw==" - }, - "@better-fetch/fetch@1.1.18": { - "integrity": "sha512-rEFOE1MYIsBmoMJtQbl32PGHHXuG2hDxvEd7rUHE0vCBoFQVSDqaVs9hkZEtHCxRoY+CljXKFCOuJ8uxqw1LcA==" - }, "@biomejs/biome@2.2.4": { "integrity": "sha512-TBHU5bUy/Ok6m8c0y3pZiuO/BZoY/OcGxoLlrfQof5s8ISVwbVBdFINPQZyFfKwil8XibYWb7JMwnT8wT4WVPg==", "optionalDependencies": [ @@ -250,12 +254,6 @@ "@bufbuild/protobuf" ] }, - "@cerbos/core@0.24.1": { - "integrity": "sha512-Gt9ETQR3WDVcPlxN+HiGUDtNgWFulwS5ZjBgzJFsdb7e2GCw0tOPE9Ex1qHNZvG/0JHpFWJWIiYaSKyXcp35YQ==", - "dependencies": [ - "uuid" - ] - }, "@cerbos/core@0.25.1_@bufbuild+protobuf@2.10.1": { "integrity": "sha512-aPi8IqqgGHq9xyoBk6dYAKQ1U1athW0YZfI+7lzxxwpLlNFdZ9EwJLhaRSUFgYpUS2TBWDtX094Yn1kgB1esCQ==", "dependencies": [ @@ -264,19 +262,12 @@ "uuid" ] }, - "@cerbos/http@0.23.1": { - "integrity": "sha512-XzWFS6L7M+oUnjGEFIoQygtlmZy3zOpUobN6spGp1MAaT6GQJMRFK8P8xhY2BQjTIhqYgnoiEFOAULTkbgNIjg==", - "dependencies": [ - "@cerbos/core@0.24.1", - "qs" - ] - }, "@cerbos/http@0.23.3_@bufbuild+protobuf@2.10.1_@cerbos+api@0.2.0": { "integrity": "sha512-yf8s4v+T4sf/ZiLorHpXhdStOr+q5XEjF2m/yvpcR7E/7e5eGCr5yEov9NIgfRQg1HGW8h+B6CIFBl9amSsaGw==", "dependencies": [ "@bufbuild/protobuf", "@cerbos/api", - "@cerbos/core@0.25.1_@bufbuild+protobuf@2.10.1", + "@cerbos/core", "qs" ] }, @@ -464,8 +455,29 @@ "levn" ] }, - "@hexagon/base64@1.1.28": { - "integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==" + "@floating-ui/core@1.7.3": { + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "dependencies": [ + "@floating-ui/utils" + ] + }, + "@floating-ui/dom@1.7.4": { + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "dependencies": [ + "@floating-ui/core", + "@floating-ui/utils" + ] + }, + "@floating-ui/react-dom@2.1.6_react@19.1.1_react-dom@19.1.1__react@19.1.1": { + "integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==", + "dependencies": [ + "@floating-ui/dom", + "react", + "react-dom" + ] + }, + "@floating-ui/utils@0.2.10": { + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==" }, "@humanfs/core@0.19.1": { "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==" @@ -601,10 +613,6 @@ ], "tarball": "https://npm.jsr.io/~/11/@jsr/valkyr__event-store/2.0.1.tgz" }, - "@jsr/valkyr__inverse@1.0.1": { - "integrity": "sha512-uZpzPct9FGobgl6H+iR3VJlzZbTFVmJSrB4z5In8zHgIJCkmgYj0diU3soU6MuiKR7SFBfD4PGSuUpTTJHNMlg==", - "tarball": "https://npm.jsr.io/~/11/@jsr/valkyr__inverse/1.0.1.tgz" - }, "@jsr/valkyr__json-rpc@1.1.0": { "integrity": "sha512-i1dwWLI29i5mqRvS2NbI3jUyw8uZuO71hJRvT5+sGAexG8RmQJP4N+ETJkxq0RNwNAGGG1bocuzdqnawa2ahIA==", "dependencies": [ @@ -623,21 +631,12 @@ ], "tarball": "https://npm.jsr.io/~/11/@jsr/valkyr__testcontainers/2.0.2.tgz" }, - "@levischuck/tiny-cbor@0.2.11": { - "integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==" - }, "@mongodb-js/saslprep@1.3.0": { "integrity": "sha512-zlayKCsIjYb7/IdfqxorK5+xUMyi4vOKcFy10wKJYc63NSdKI8mNME+uJqfatkPmOSMMUiojrL58IePKBm3gvQ==", "dependencies": [ "sparse-bitfield" ] }, - "@noble/ciphers@2.0.1": { - "integrity": "sha512-xHK3XHPUW8DTAobU+G0XT+/w+JLM7/8k1UFdB5xg/zTFPnFCobhftzw8wl4Lw2aq/Rvir5pxfZV5fEazmeCJ2g==" - }, - "@noble/hashes@2.0.0": { - "integrity": "sha512-h8VUBlE8R42+XIDO229cgisD287im3kdY6nbNZJFjc6ZvKIXPYXe6Vc/t+kyjFdMFyt5JpapzTsEg8n63w5/lw==" - }, "@nodelib/fs.scandir@2.1.5": { "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", "dependencies": [ @@ -655,49 +654,524 @@ "fastq" ] }, - "@peculiar/asn1-android@2.5.0": { - "integrity": "sha512-t8A83hgghWQkcneRsgGs2ebAlRe54ns88p7ouv8PW2tzF1nAW4yHcL4uZKrFpIU+uszIRzTkcCuie37gpkId0A==", + "@radix-ui/number@1.1.1": { + "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==" + }, + "@radix-ui/primitive@1.1.3": { + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==" + }, + "@radix-ui/react-arrow@1.1.7_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1": { + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", "dependencies": [ - "@peculiar/asn1-schema", - "asn1js", - "tslib" + "@radix-ui/react-primitive@2.1.3_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1", + "@types/react", + "@types/react-dom", + "react", + "react-dom" + ], + "optionalPeers": [ + "@types/react", + "@types/react-dom" ] }, - "@peculiar/asn1-ecc@2.5.0": { - "integrity": "sha512-t4eYGNhXtLRxaP50h3sfO6aJebUCDGQACoeexcelL4roMFRRVgB20yBIu2LxsPh/tdW9I282gNgMOyg3ywg/mg==", + "@radix-ui/react-avatar@1.1.11_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1": { + "integrity": "sha512-0Qk603AHGV28BOBO34p7IgD5m+V5Sg/YovfayABkoDDBM5d3NCx0Mp4gGrjzLGes1jV5eNOE1r3itqOR33VC6Q==", "dependencies": [ - "@peculiar/asn1-schema", - "@peculiar/asn1-x509", - "asn1js", - "tslib" + "@radix-ui/react-context@1.1.3_@types+react@19.1.13_react@19.1.1", + "@radix-ui/react-primitive@2.1.4_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1", + "@radix-ui/react-use-callback-ref", + "@radix-ui/react-use-is-hydrated", + "@radix-ui/react-use-layout-effect", + "@types/react", + "@types/react-dom", + "react", + "react-dom" + ], + "optionalPeers": [ + "@types/react", + "@types/react-dom" ] }, - "@peculiar/asn1-rsa@2.5.0": { - "integrity": "sha512-qMZ/vweiTHy9syrkkqWFvbT3eLoedvamcUdnnvwyyUNv5FgFXA3KP8td+ATibnlZ0EANW5PYRm8E6MJzEB/72Q==", + "@radix-ui/react-collection@1.1.7_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1": { + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", "dependencies": [ - "@peculiar/asn1-schema", - "@peculiar/asn1-x509", - "asn1js", - "tslib" + "@radix-ui/react-compose-refs", + "@radix-ui/react-context@1.1.2_@types+react@19.1.13_react@19.1.1", + "@radix-ui/react-primitive@2.1.3_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1", + "@radix-ui/react-slot@1.2.3_@types+react@19.1.13_react@19.1.1", + "@types/react", + "@types/react-dom", + "react", + "react-dom" + ], + "optionalPeers": [ + "@types/react", + "@types/react-dom" ] }, - "@peculiar/asn1-schema@2.5.0": { - "integrity": "sha512-YM/nFfskFJSlHqv59ed6dZlLZqtZQwjRVJ4bBAiWV08Oc+1rSd5lDZcBEx0lGDHfSoH3UziI2pXt2UM33KerPQ==", + "@radix-ui/react-compose-refs@1.1.2_@types+react@19.1.13_react@19.1.1": { + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", "dependencies": [ - "asn1js", - "pvtsutils", - "tslib" + "@types/react", + "react" + ], + "optionalPeers": [ + "@types/react" ] }, - "@peculiar/asn1-x509@2.5.0": { - "integrity": "sha512-CpwtMCTJvfvYTFMuiME5IH+8qmDe3yEWzKHe7OOADbGfq7ohxeLaXwQo0q4du3qs0AII3UbLCvb9NF/6q0oTKQ==", + "@radix-ui/react-context@1.1.2_@types+react@19.1.13_react@19.1.1": { + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", "dependencies": [ - "@peculiar/asn1-schema", - "asn1js", - "pvtsutils", - "tslib" + "@types/react", + "react" + ], + "optionalPeers": [ + "@types/react" ] }, + "@radix-ui/react-context@1.1.3_@types+react@19.1.13_react@19.1.1": { + "integrity": "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==", + "dependencies": [ + "@types/react", + "react" + ], + "optionalPeers": [ + "@types/react" + ] + }, + "@radix-ui/react-dialog@1.1.15_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1": { + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", + "dependencies": [ + "@radix-ui/primitive", + "@radix-ui/react-compose-refs", + "@radix-ui/react-context@1.1.2_@types+react@19.1.13_react@19.1.1", + "@radix-ui/react-dismissable-layer", + "@radix-ui/react-focus-guards", + "@radix-ui/react-focus-scope", + "@radix-ui/react-id", + "@radix-ui/react-portal", + "@radix-ui/react-presence", + "@radix-ui/react-primitive@2.1.3_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1", + "@radix-ui/react-slot@1.2.3_@types+react@19.1.13_react@19.1.1", + "@radix-ui/react-use-controllable-state", + "@types/react", + "@types/react-dom", + "aria-hidden", + "react", + "react-dom", + "react-remove-scroll" + ], + "optionalPeers": [ + "@types/react", + "@types/react-dom" + ] + }, + "@radix-ui/react-direction@1.1.1_@types+react@19.1.13_react@19.1.1": { + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "dependencies": [ + "@types/react", + "react" + ], + "optionalPeers": [ + "@types/react" + ] + }, + "@radix-ui/react-dismissable-layer@1.1.11_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1": { + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "dependencies": [ + "@radix-ui/primitive", + "@radix-ui/react-compose-refs", + "@radix-ui/react-primitive@2.1.3_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1", + "@radix-ui/react-use-callback-ref", + "@radix-ui/react-use-escape-keydown", + "@types/react", + "@types/react-dom", + "react", + "react-dom" + ], + "optionalPeers": [ + "@types/react", + "@types/react-dom" + ] + }, + "@radix-ui/react-dropdown-menu@2.1.16_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1": { + "integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==", + "dependencies": [ + "@radix-ui/primitive", + "@radix-ui/react-compose-refs", + "@radix-ui/react-context@1.1.2_@types+react@19.1.13_react@19.1.1", + "@radix-ui/react-id", + "@radix-ui/react-menu", + "@radix-ui/react-primitive@2.1.3_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1", + "@radix-ui/react-use-controllable-state", + "@types/react", + "@types/react-dom", + "react", + "react-dom" + ], + "optionalPeers": [ + "@types/react", + "@types/react-dom" + ] + }, + "@radix-ui/react-focus-guards@1.1.3_@types+react@19.1.13_react@19.1.1": { + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "dependencies": [ + "@types/react", + "react" + ], + "optionalPeers": [ + "@types/react" + ] + }, + "@radix-ui/react-focus-scope@1.1.7_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1": { + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "dependencies": [ + "@radix-ui/react-compose-refs", + "@radix-ui/react-primitive@2.1.3_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1", + "@radix-ui/react-use-callback-ref", + "@types/react", + "@types/react-dom", + "react", + "react-dom" + ], + "optionalPeers": [ + "@types/react", + "@types/react-dom" + ] + }, + "@radix-ui/react-id@1.1.1_@types+react@19.1.13_react@19.1.1": { + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "dependencies": [ + "@radix-ui/react-use-layout-effect", + "@types/react", + "react" + ], + "optionalPeers": [ + "@types/react" + ] + }, + "@radix-ui/react-menu@2.1.16_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1": { + "integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==", + "dependencies": [ + "@radix-ui/primitive", + "@radix-ui/react-collection", + "@radix-ui/react-compose-refs", + "@radix-ui/react-context@1.1.2_@types+react@19.1.13_react@19.1.1", + "@radix-ui/react-direction", + "@radix-ui/react-dismissable-layer", + "@radix-ui/react-focus-guards", + "@radix-ui/react-focus-scope", + "@radix-ui/react-id", + "@radix-ui/react-popper", + "@radix-ui/react-portal", + "@radix-ui/react-presence", + "@radix-ui/react-primitive@2.1.3_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1", + "@radix-ui/react-roving-focus", + "@radix-ui/react-slot@1.2.3_@types+react@19.1.13_react@19.1.1", + "@radix-ui/react-use-callback-ref", + "@types/react", + "@types/react-dom", + "aria-hidden", + "react", + "react-dom", + "react-remove-scroll" + ], + "optionalPeers": [ + "@types/react", + "@types/react-dom" + ] + }, + "@radix-ui/react-popper@1.2.8_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1": { + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "dependencies": [ + "@floating-ui/react-dom", + "@radix-ui/react-arrow", + "@radix-ui/react-compose-refs", + "@radix-ui/react-context@1.1.2_@types+react@19.1.13_react@19.1.1", + "@radix-ui/react-primitive@2.1.3_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1", + "@radix-ui/react-use-callback-ref", + "@radix-ui/react-use-layout-effect", + "@radix-ui/react-use-rect", + "@radix-ui/react-use-size", + "@radix-ui/rect", + "@types/react", + "@types/react-dom", + "react", + "react-dom" + ], + "optionalPeers": [ + "@types/react", + "@types/react-dom" + ] + }, + "@radix-ui/react-portal@1.1.9_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1": { + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "dependencies": [ + "@radix-ui/react-primitive@2.1.3_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1", + "@radix-ui/react-use-layout-effect", + "@types/react", + "@types/react-dom", + "react", + "react-dom" + ], + "optionalPeers": [ + "@types/react", + "@types/react-dom" + ] + }, + "@radix-ui/react-presence@1.1.5_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1": { + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "dependencies": [ + "@radix-ui/react-compose-refs", + "@radix-ui/react-use-layout-effect", + "@types/react", + "@types/react-dom", + "react", + "react-dom" + ], + "optionalPeers": [ + "@types/react", + "@types/react-dom" + ] + }, + "@radix-ui/react-primitive@2.1.3_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1": { + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "dependencies": [ + "@radix-ui/react-slot@1.2.3_@types+react@19.1.13_react@19.1.1", + "@types/react", + "@types/react-dom", + "react", + "react-dom" + ], + "optionalPeers": [ + "@types/react", + "@types/react-dom" + ] + }, + "@radix-ui/react-primitive@2.1.4_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1": { + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "dependencies": [ + "@radix-ui/react-slot@1.2.4_@types+react@19.1.13_react@19.1.1", + "@types/react", + "@types/react-dom", + "react", + "react-dom" + ], + "optionalPeers": [ + "@types/react", + "@types/react-dom" + ] + }, + "@radix-ui/react-roving-focus@1.1.11_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1": { + "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", + "dependencies": [ + "@radix-ui/primitive", + "@radix-ui/react-collection", + "@radix-ui/react-compose-refs", + "@radix-ui/react-context@1.1.2_@types+react@19.1.13_react@19.1.1", + "@radix-ui/react-direction", + "@radix-ui/react-id", + "@radix-ui/react-primitive@2.1.3_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1", + "@radix-ui/react-use-callback-ref", + "@radix-ui/react-use-controllable-state", + "@types/react", + "@types/react-dom", + "react", + "react-dom" + ], + "optionalPeers": [ + "@types/react", + "@types/react-dom" + ] + }, + "@radix-ui/react-scroll-area@1.2.10_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1": { + "integrity": "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==", + "dependencies": [ + "@radix-ui/number", + "@radix-ui/primitive", + "@radix-ui/react-compose-refs", + "@radix-ui/react-context@1.1.2_@types+react@19.1.13_react@19.1.1", + "@radix-ui/react-direction", + "@radix-ui/react-presence", + "@radix-ui/react-primitive@2.1.3_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1", + "@radix-ui/react-use-callback-ref", + "@radix-ui/react-use-layout-effect", + "@types/react", + "@types/react-dom", + "react", + "react-dom" + ], + "optionalPeers": [ + "@types/react", + "@types/react-dom" + ] + }, + "@radix-ui/react-separator@1.1.8_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1": { + "integrity": "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==", + "dependencies": [ + "@radix-ui/react-primitive@2.1.4_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1", + "@types/react", + "@types/react-dom", + "react", + "react-dom" + ], + "optionalPeers": [ + "@types/react", + "@types/react-dom" + ] + }, + "@radix-ui/react-slot@1.2.3_@types+react@19.1.13_react@19.1.1": { + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "dependencies": [ + "@radix-ui/react-compose-refs", + "@types/react", + "react" + ], + "optionalPeers": [ + "@types/react" + ] + }, + "@radix-ui/react-slot@1.2.4_@types+react@19.1.13_react@19.1.1": { + "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", + "dependencies": [ + "@radix-ui/react-compose-refs", + "@types/react", + "react" + ], + "optionalPeers": [ + "@types/react" + ] + }, + "@radix-ui/react-tooltip@1.2.8_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1": { + "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==", + "dependencies": [ + "@radix-ui/primitive", + "@radix-ui/react-compose-refs", + "@radix-ui/react-context@1.1.2_@types+react@19.1.13_react@19.1.1", + "@radix-ui/react-dismissable-layer", + "@radix-ui/react-id", + "@radix-ui/react-popper", + "@radix-ui/react-portal", + "@radix-ui/react-presence", + "@radix-ui/react-primitive@2.1.3_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1", + "@radix-ui/react-slot@1.2.3_@types+react@19.1.13_react@19.1.1", + "@radix-ui/react-use-controllable-state", + "@radix-ui/react-visually-hidden", + "@types/react", + "@types/react-dom", + "react", + "react-dom" + ], + "optionalPeers": [ + "@types/react", + "@types/react-dom" + ] + }, + "@radix-ui/react-use-callback-ref@1.1.1_@types+react@19.1.13_react@19.1.1": { + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "dependencies": [ + "@types/react", + "react" + ], + "optionalPeers": [ + "@types/react" + ] + }, + "@radix-ui/react-use-controllable-state@1.2.2_@types+react@19.1.13_react@19.1.1": { + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "dependencies": [ + "@radix-ui/react-use-effect-event", + "@radix-ui/react-use-layout-effect", + "@types/react", + "react" + ], + "optionalPeers": [ + "@types/react" + ] + }, + "@radix-ui/react-use-effect-event@0.0.2_@types+react@19.1.13_react@19.1.1": { + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "dependencies": [ + "@radix-ui/react-use-layout-effect", + "@types/react", + "react" + ], + "optionalPeers": [ + "@types/react" + ] + }, + "@radix-ui/react-use-escape-keydown@1.1.1_@types+react@19.1.13_react@19.1.1": { + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "dependencies": [ + "@radix-ui/react-use-callback-ref", + "@types/react", + "react" + ], + "optionalPeers": [ + "@types/react" + ] + }, + "@radix-ui/react-use-is-hydrated@0.1.0_@types+react@19.1.13_react@19.1.1": { + "integrity": "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==", + "dependencies": [ + "@types/react", + "react", + "use-sync-external-store" + ], + "optionalPeers": [ + "@types/react" + ] + }, + "@radix-ui/react-use-layout-effect@1.1.1_@types+react@19.1.13_react@19.1.1": { + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "dependencies": [ + "@types/react", + "react" + ], + "optionalPeers": [ + "@types/react" + ] + }, + "@radix-ui/react-use-rect@1.1.1_@types+react@19.1.13_react@19.1.1": { + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "dependencies": [ + "@radix-ui/rect", + "@types/react", + "react" + ], + "optionalPeers": [ + "@types/react" + ] + }, + "@radix-ui/react-use-size@1.1.1_@types+react@19.1.13_react@19.1.1": { + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "dependencies": [ + "@radix-ui/react-use-layout-effect", + "@types/react", + "react" + ], + "optionalPeers": [ + "@types/react" + ] + }, + "@radix-ui/react-visually-hidden@1.2.3_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1": { + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "dependencies": [ + "@radix-ui/react-primitive@2.1.3_@types+react@19.1.13_@types+react-dom@19.1.9__@types+react@19.1.13_react@19.1.1_react-dom@19.1.1__react@19.1.1", + "@types/react", + "@types/react-dom", + "react", + "react-dom" + ], + "optionalPeers": [ + "@types/react", + "@types/react-dom" + ] + }, + "@radix-ui/rect@1.1.1": { + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==" + }, "@rolldown/pluginutils@1.0.0-beta.27": { "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==" }, @@ -811,21 +1285,16 @@ "os": ["win32"], "cpu": ["x64"] }, - "@simplewebauthn/browser@13.2.0": { - "integrity": "sha512-N3fuA1AAnTo5gCStYoIoiasPccC+xPLx2YU88Dv0GeAmPQTWHETlZQq5xZ0DgUq1H9loXMWQH5qqUjcI7BHJ1A==" - }, - "@simplewebauthn/server@13.1.2": { - "integrity": "sha512-VwoDfvLXSCaRiD+xCIuyslU0HLxVggeE5BL06+GbsP2l1fGf5op8e0c3ZtKoi+vSg1q4ikjtAghC23ze2Q3H9g==", + "@tabler/icons-react@3.35.0_react@19.1.1": { + "integrity": "sha512-XG7t2DYf3DyHT5jxFNp5xyLVbL4hMJYJhiSdHADzAjLRYfL7AnjlRfiHDHeXxkb2N103rEIvTsBRazxXtAUz2g==", "dependencies": [ - "@hexagon/base64", - "@levischuck/tiny-cbor", - "@peculiar/asn1-android", - "@peculiar/asn1-ecc", - "@peculiar/asn1-rsa", - "@peculiar/asn1-schema", - "@peculiar/asn1-x509" + "@tabler/icons", + "react" ] }, + "@tabler/icons@3.35.0": { + "integrity": "sha512-yYXe+gJ56xlZFiXwV9zVoe3FWCGuZ/D7/G4ZIlDtGxSx5CGQK110wrnT29gUj52kEZoxqF7oURTk97GQxELOFQ==" + }, "@tailwindcss/node@4.1.13": { "integrity": "sha512-eq3ouolC1oEFOAvOMOBAmfCIqZBJuvWvvYWh5h5iOYfe1HFC6+GZ6EIL0JdM3/niGRJmnrOc+8gl9/HGUaaptw==", "dependencies": [ @@ -1171,6 +1640,12 @@ "vite" ] }, + "@zitadel/react@1.1.0": { + "integrity": "sha512-aMad1iZNpsZgEtUvSIyjCt1TdCzg++OMg3GwdPbFhnHTHwQMoSRB9IrYuD0grHK0TqU7yx283iJO5te2ToRWtA==", + "dependencies": [ + "oidc-client-ts" + ] + }, "acorn-jsx@5.3.2_acorn@8.15.0": { "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dependencies": [ @@ -1199,11 +1674,9 @@ "argparse@2.0.1": { "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" }, - "asn1js@3.0.6": { - "integrity": "sha512-UOCGPYbl0tv8+006qks/dTgV9ajs97X2p0FAbyS2iyCRrmLSRolDaHdp+v/CLgnzHc3fVB+CwYiUmei7ndFcgA==", + "aria-hidden@1.2.6": { + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", "dependencies": [ - "pvtsutils", - "pvutils", "tslib" ] }, @@ -1214,39 +1687,6 @@ "integrity": "sha512-wrH5NNqren/QMtKUEEJf7z86YjfqW/2uw3IL3/xpqZUC95SSVIFXYQeeGjL6FT/X68IROu6RMehZQS5foy2BXw==", "bin": true }, - "better-auth@1.3.16_react@19.1.1_react-dom@19.1.1__react@19.1.1": { - "integrity": "sha512-WHU3QTtkBdwMlzM4AbOSoti0aPVTw1IfdomNCcP9Mo0J03ENn7z2a6/Vz9fimHmjpVWMqRvW72cYcc30LLFFOw==", - "dependencies": [ - "@better-auth/utils", - "@better-fetch/fetch", - "@noble/ciphers", - "@noble/hashes", - "@simplewebauthn/browser", - "@simplewebauthn/server", - "better-call", - "defu", - "jose", - "kysely", - "nanostores", - "react", - "react-dom", - "zod" - ], - "optionalPeers": [ - "react", - "react-dom" - ] - }, - "better-call@1.0.19": { - "integrity": "sha512-sI3GcA1SCVa3H+CDHl8W8qzhlrckwXOTKhqq3OOPXjgn5aTOMIqGY34zLY/pHA6tRRMjTUC3lz5Mi7EbDA24Kw==", - "dependencies": [ - "@better-auth/utils", - "@better-fetch/fetch", - "rou3", - "set-cookie-parser", - "uncrypto" - ] - }, "brace-expansion@1.1.12": { "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dependencies": [ @@ -1310,6 +1750,12 @@ "chownr@3.0.0": { "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==" }, + "class-variance-authority@0.7.1": { + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "dependencies": [ + "clsx" + ] + }, "clsx@2.1.1": { "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==" }, @@ -1331,9 +1777,6 @@ "cookie-es@1.2.2": { "integrity": "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==" }, - "cookie@1.0.2": { - "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==" - }, "cross-spawn@7.0.6": { "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dependencies": [ @@ -1342,6 +1785,9 @@ "which" ] }, + "crypto-js@4.2.0": { + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==" + }, "csstype@3.1.3": { "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" }, @@ -1354,12 +1800,12 @@ "deep-is@0.1.4": { "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==" }, - "defu@6.1.4": { - "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==" - }, "detect-libc@2.1.0": { "integrity": "sha512-vEtk+OcP7VBRtQZ1EJ3bdgzSfBjgnEalLTp5zjJrS+2Z1w2KZly4SBdac/WDU3hhsNAZ9E8SC96ME4Ey8MZ7cg==" }, + "detect-node-es@1.1.0": { + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==" + }, "dunder-proto@1.0.1": { "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", "dependencies": [ @@ -1616,6 +2062,9 @@ "math-intrinsics" ] }, + "get-nonce@1.0.1": { + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==" + }, "get-proto@1.0.1": { "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", "dependencies": [ @@ -1739,15 +2188,15 @@ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "bin": true }, + "jwt-decode@3.1.2": { + "integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A==" + }, "keyv@4.5.4": { "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "dependencies": [ "json-buffer" ] }, - "kysely@0.28.5": { - "integrity": "sha512-rlB0I/c6FBDWPcQoDtkxi9zIvpmnV5xoIalfCMSMCa7nuA6VGA3F54TW9mEgX4DVf10sXAWCF5fDbamI/5ZpKA==" - }, "levn@0.4.1": { "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", "dependencies": [ @@ -1838,6 +2287,12 @@ "yallist@3.1.1" ] }, + "lucide-react@0.554.0_react@19.1.1": { + "integrity": "sha512-St+z29uthEJVx0Is7ellNkgTEhaeSoA42I7JjOCBCrc5X6LYMGSv0P/2uS5HDLTExP5tpiqRD2PyUEOS6s9UXA==", + "dependencies": [ + "react" + ] + }, "magic-string@0.30.19": { "integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==", "dependencies": [ @@ -1910,9 +2365,6 @@ "integrity": "sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==", "bin": true }, - "nanostores@1.0.1": { - "integrity": "sha512-kNZ9xnoJYKg/AfxjrVL4SS0fKX++4awQReGqWnwTRHxeHGZ1FJFVgTqr/eMrNQdp0Tz7M7tG/TDaX8QfHDwVCw==" - }, "natural-compare@1.4.0": { "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==" }, @@ -1922,6 +2374,13 @@ "object-inspect@1.13.4": { "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==" }, + "oidc-client-ts@2.4.1": { + "integrity": "sha512-IxlGMsbkZPsHJGCliWT3LxjUcYzmiN21656n/Zt2jDncZlBFc//cd8WqFF0Lt681UT3AImM57E6d4N53ziTCYA==", + "dependencies": [ + "crypto-js", + "jwt-decode" + ] + }, "optionator@0.9.4": { "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "dependencies": [ @@ -1986,15 +2445,6 @@ "punycode@2.3.1": { "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==" }, - "pvtsutils@1.3.6": { - "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", - "dependencies": [ - "tslib" - ] - }, - "pvutils@1.1.3": { - "integrity": "sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==" - }, "qs@6.14.0": { "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", "dependencies": [ @@ -2014,6 +2464,45 @@ "react-refresh@0.17.0": { "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==" }, + "react-remove-scroll-bar@2.3.8_@types+react@19.1.13_react@19.1.1": { + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "dependencies": [ + "@types/react", + "react", + "react-style-singleton", + "tslib" + ], + "optionalPeers": [ + "@types/react" + ] + }, + "react-remove-scroll@2.7.1_@types+react@19.1.13_react@19.1.1": { + "integrity": "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA==", + "dependencies": [ + "@types/react", + "react", + "react-remove-scroll-bar", + "react-style-singleton", + "tslib", + "use-callback-ref", + "use-sidecar" + ], + "optionalPeers": [ + "@types/react" + ] + }, + "react-style-singleton@2.2.3_@types+react@19.1.13_react@19.1.1": { + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "dependencies": [ + "@types/react", + "get-nonce", + "react", + "tslib" + ], + "optionalPeers": [ + "@types/react" + ] + }, "react@19.1.1": { "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==" }, @@ -2055,9 +2544,6 @@ ], "bin": true }, - "rou3@0.5.1": { - "integrity": "sha512-OXMmJ3zRk2xeXFGfA3K+EOPHC5u7RDFG7lIOx0X1pdnhUkI8MdVrbV+sNsD80ElpUZ+MRHdyxPnFthq9VHs8uQ==" - }, "run-parallel@1.2.0": { "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", "dependencies": [ @@ -2090,9 +2576,6 @@ "seroval@1.3.2": { "integrity": "sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==" }, - "set-cookie-parser@2.7.1": { - "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==" - }, "shebang-command@2.0.0": { "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "dependencies": [ @@ -2164,6 +2647,15 @@ "has-flag" ] }, + "tailwind-merge@3.4.0": { + "integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==" + }, + "tailwindcss-animate@1.0.7_tailwindcss@4.1.13": { + "integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==", + "dependencies": [ + "tailwindcss" + ] + }, "tailwindcss@4.1.13": { "integrity": "sha512-i+zidfmTqtwquj4hMEwdjshYYgMbOrPzb9a0M3ZgNa0JMoZeFC6bxZvO8yr8ozS6ix2SDz0+mvryPeBs2TFE+w==" }, @@ -2214,6 +2706,9 @@ "tslib@2.8.1": { "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, + "tw-animate-css@1.4.0": { + "integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==" + }, "type-check@0.4.0": { "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", "dependencies": [ @@ -2235,9 +2730,6 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "bin": true }, - "uncrypto@0.1.3": { - "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==" - }, "undici-types@7.10.0": { "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==" }, @@ -2256,6 +2748,29 @@ "punycode" ] }, + "use-callback-ref@1.3.3_@types+react@19.1.13_react@19.1.1": { + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "dependencies": [ + "@types/react", + "react", + "tslib" + ], + "optionalPeers": [ + "@types/react" + ] + }, + "use-sidecar@1.1.3_@types+react@19.1.13_react@19.1.1": { + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "dependencies": [ + "@types/react", + "detect-node-es", + "react", + "tslib" + ], + "optionalPeers": [ + "@types/react" + ] + }, "use-sync-external-store@1.5.0_react@19.1.1": { "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", "dependencies": [ @@ -2314,8 +2829,8 @@ "yocto-queue@0.1.0": { "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==" }, - "zod@4.1.11": { - "integrity": "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==" + "zod@4.1.12": { + "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==" } }, "workspace": { @@ -2330,7 +2845,7 @@ "api": { "packageJson": { "dependencies": [ - "npm:zod@4.1.11" + "npm:zod@4.1.12" ] } }, @@ -2340,6 +2855,14 @@ "npm:@eslint/js@9.35.0", "npm:@jsr/valkyr__db@2.0.0", "npm:@jsr/valkyr__event-emitter@1.0.1", + "npm:@radix-ui/react-avatar@^1.1.11", + "npm:@radix-ui/react-dialog@^1.1.15", + "npm:@radix-ui/react-dropdown-menu@^2.1.16", + "npm:@radix-ui/react-scroll-area@^1.2.10", + "npm:@radix-ui/react-separator@^1.1.8", + "npm:@radix-ui/react-slot@^1.2.4", + "npm:@radix-ui/react-tooltip@^1.2.8", + "npm:@tabler/icons-react@3.35.0", "npm:@tailwindcss/vite@4.1.13", "npm:@tanstack/react-query@5.89.0", "npm:@tanstack/react-router-devtools@1.131.47", @@ -2347,38 +2870,32 @@ "npm:@types/react-dom@19.1.9", "npm:@types/react@19.1.13", "npm:@vitejs/plugin-react@4.7.0", + "npm:@zitadel/react@1.1.0", + "npm:class-variance-authority@~0.7.1", + "npm:clsx@^2.1.1", "npm:eslint-plugin-react-hooks@5.2.0", "npm:eslint-plugin-react-refresh@0.4.20", "npm:eslint@9.35.0", "npm:fast-equals@5.2.2", "npm:globals@16.4.0", + "npm:lucide-react@0.554", "npm:react-dom@19.1.1", "npm:react@19.1.1", + "npm:tailwind-merge@^3.4.0", + "npm:tailwindcss-animate@^1.0.7", "npm:tailwindcss@4.1.13", + "npm:tw-animate-css@1.4.0", "npm:typescript-eslint@8.44.0", "npm:typescript@5.9.2", "npm:vite@7.1.6", - "npm:zod@4.1.11" + "npm:zod@4.1.12" ] } }, - "modules/iam": { + "modules/account": { "packageJson": { "dependencies": [ - "npm:@cerbos/core@0.24.1", - "npm:@cerbos/http@0.23.1", - "npm:better-auth@1.3.16", - "npm:cookie@1.0.2", - "npm:zod@4.1.11" - ] - } - }, - "modules/workspace": { - "packageJson": { - "dependencies": [ - "npm:@jsr/valkyr__event-store@2.0.1", - "npm:cookie@1.0.2", - "npm:zod@4.1.11" + "npm:zod@4.1.12" ] } }, @@ -2394,16 +2911,15 @@ "packageJson": { "dependencies": [ "npm:@jsr/std__dotenv@0.225.5", - "npm:zod@4.1.11" + "npm:zod@4.1.12" ] } }, "platform/database": { "packageJson": { "dependencies": [ - "npm:@jsr/valkyr__inverse@1.0.1", - "npm:mongodb@6.20.0", - "npm:zod@4.1.11" + "npm:postgres@3.4.7", + "npm:zod@4.1.12" ] } }, @@ -2411,7 +2927,7 @@ "packageJson": { "dependencies": [ "npm:@jsr/valkyr__event-store@2.0.1", - "npm:zod@4.1.11" + "npm:zod@4.1.12" ] } }, @@ -2419,14 +2935,14 @@ "packageJson": { "dependencies": [ "npm:path-to-regexp@8", - "npm:zod@4.1.11" + "npm:zod@4.1.12" ] } }, "platform/routes": { "packageJson": { "dependencies": [ - "npm:zod@4.1.11" + "npm:zod@4.1.12" ] } }, @@ -2434,7 +2950,7 @@ "packageJson": { "dependencies": [ "npm:@jsr/valkyr__json-rpc@1.1.0", - "npm:zod@4.1.11" + "npm:zod@4.1.12" ] } }, @@ -2448,7 +2964,7 @@ "platform/spec": { "packageJson": { "dependencies": [ - "npm:zod@4.1.11" + "npm:zod@4.1.12" ] } }, diff --git a/modules/account/client.ts b/modules/account/client.ts new file mode 100644 index 0000000..8982b52 --- /dev/null +++ b/modules/account/client.ts @@ -0,0 +1,4 @@ +export const account = { + create: (await import("./routes/create/spec.ts")).default, + get: (await import("./routes/get/spec.ts")).default, +}; diff --git a/modules/account/package.json b/modules/account/package.json new file mode 100644 index 0000000..dd5612c --- /dev/null +++ b/modules/account/package.json @@ -0,0 +1,14 @@ +{ + "name": "@module/account", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + "./server": "./server.ts", + "./client": "./client.ts" + }, + "dependencies": { + "@platform/relay": "workspace:*", + "zod": "4.1.12" + } +} diff --git a/modules/account/routes/create/handle.ts b/modules/account/routes/create/handle.ts new file mode 100644 index 0000000..23a8dc8 --- /dev/null +++ b/modules/account/routes/create/handle.ts @@ -0,0 +1,5 @@ +import route from "./spec.ts"; + +export default route.handle(async ({ body }) => { + console.log(body); +}); diff --git a/modules/account/routes/create/spec.ts b/modules/account/routes/create/spec.ts new file mode 100644 index 0000000..b58c38a --- /dev/null +++ b/modules/account/routes/create/spec.ts @@ -0,0 +1,13 @@ +import { route } from "@platform/relay"; +import z from "zod"; + +export default route.post("/api/v1/account").body( + z.strictObject({ + tenantId: z.uuid().describe("Tenant identifier the account belongs to"), + userId: z.uuid().describe("User identifier the account belongs to"), + account: z.strictObject({ + type: z.string().describe("Type of account being created"), + number: z.number().describe("Unique account identifier to create for the account"), + }), + }), +); diff --git a/modules/account/routes/get/handle.ts b/modules/account/routes/get/handle.ts new file mode 100644 index 0000000..1cd205b --- /dev/null +++ b/modules/account/routes/get/handle.ts @@ -0,0 +1,5 @@ +import route from "./spec.ts"; + +export default route.handle(async ({ params }) => { + console.log(params); +}); diff --git a/modules/account/routes/get/spec.ts b/modules/account/routes/get/spec.ts new file mode 100644 index 0000000..f6ec5f1 --- /dev/null +++ b/modules/account/routes/get/spec.ts @@ -0,0 +1,6 @@ +import { route } from "@platform/relay"; +import z from "zod"; + +export default route.get("/api/v1/account/:number").params({ + number: z.number().describe("Account number to retrieve"), +}); diff --git a/modules/workspace/client.ts b/modules/account/server.ts similarity index 100% rename from modules/workspace/client.ts rename to modules/account/server.ts diff --git a/modules/iam/cerbos/client.ts b/modules/iam/cerbos/client.ts deleted file mode 100644 index 7248ad4..0000000 --- a/modules/iam/cerbos/client.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { HTTP } from "@cerbos/http"; -import { getEnvironmentVariable } from "@platform/config/environment.ts"; -import z from "zod"; - -export const cerbos = new HTTP( - getEnvironmentVariable({ - key: "CERBOS_URL", - type: z.string(), - fallback: "http://localhost:3592", - }), - { - adminCredentials: { - username: "cerbos", - password: "cerbosAdmin", - }, - }, -); diff --git a/modules/iam/cerbos/policies/identity.yaml b/modules/iam/cerbos/policies/identity.yaml deleted file mode 100644 index 57ff976..0000000 --- a/modules/iam/cerbos/policies/identity.yaml +++ /dev/null @@ -1,23 +0,0 @@ -# yaml-language-server: $schema=https://api.cerbos.dev/latest/cerbos/policy/v1/Policy.schema.json -# docs: https://docs.cerbos.dev/cerbos/latest/policies/resource_policies - -apiVersion: api.cerbos.dev/v1 -resourcePolicy: - resource: identity - version: default - rules: - - # Admins can read any identity with limited fields - - - actions: ["read", "update"] - effect: EFFECT_ALLOW - roles: ["admin"] - - # Users can fully read, update, or delete their own identity - - - actions: ["read", "update", "delete"] - effect: EFFECT_ALLOW - roles: ["user"] - condition: - match: - expr: request.resource.id == request.principal.id diff --git a/modules/iam/cerbos/policies/role.yaml b/modules/iam/cerbos/policies/role.yaml deleted file mode 100644 index 72f3dad..0000000 --- a/modules/iam/cerbos/policies/role.yaml +++ /dev/null @@ -1,14 +0,0 @@ -# yaml-language-server: $schema=https://api.cerbos.dev/latest/cerbos/policy/v1/Policy.schema.json -# docs: https://docs.cerbos.dev/cerbos/latest/policies/resource_policies - -apiVersion: api.cerbos.dev/v1 -resourcePolicy: - resource: role - version: default - rules: - - # Admin can manage roles - - - actions: ["manage"] - effect: EFFECT_ALLOW - roles: ["super"] diff --git a/modules/iam/cerbos/resources.ts b/modules/iam/cerbos/resources.ts deleted file mode 100644 index 0e561d3..0000000 --- a/modules/iam/cerbos/resources.ts +++ /dev/null @@ -1,11 +0,0 @@ -/* -export const resources = new ResourceRegistry([ - { - kind: "identity", - actions: ["read", "update", "delete"], - attr: {}, - }, -] as const); - -export type Resource = typeof resources.$resource; -*/ diff --git a/modules/iam/client.ts b/modules/iam/client.ts deleted file mode 100644 index 54043b2..0000000 --- a/modules/iam/client.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { CheckResourcesResponse } from "@cerbos/core"; -import { HttpAdapter, makeClient } from "@platform/relay"; - -import { config } from "./config.ts"; -import checkResource from "./routes/access/check-resource/spec.ts"; -import checkResources from "./routes/access/check-resources/spec.ts"; -import isAllowed from "./routes/access/is-allowed/spec.ts"; -import getById from "./routes/identities/get/spec.ts"; -import loginByPassword from "./routes/login/code/spec.ts"; -import loginByEmail from "./routes/login/email/spec.ts"; -import loginByCode from "./routes/login/password/spec.ts"; -import me from "./routes/me/spec.ts"; - -const adapter = new HttpAdapter({ - url: config.url, -}); - -const access = makeClient( - { - adapter, - }, - { - isAllowed, - checkResource, - checkResources, - }, -); - -export const identity = makeClient( - { - adapter, - }, - { - /** - * TODO ... - */ - getById, - - /** - * TODO ... - */ - me, - - /** - * TODO ... - */ - login: { - /** - * TODO ... - */ - email: loginByEmail, - - /** - * TODO ... - */ - password: loginByPassword, - - /** - * TODO ... - */ - code: loginByCode, - }, - - access: { - /** - * Check if a principal is allowed to perform an action on a resource. - * - * @param resource - Resource which we are validating. - * @param action - Action which we are validating. - * - * @example - * - * await access.isAllowed( - * { - * kind: "document", - * id: "1", - * attr: { owner: "user@example.com" }, - * }, - * "view" - * ); // => true - */ - isAllowed: async (resource: Resource, action: string) => { - const response = await access.isAllowed({ body: { resource, action } }); - if ("error" in response) { - throw response.error; - } - return response.data; - }, - - /** - * Check a principal's permissions on a resource. - * - * @param resource - Resource which we are validating. - * @param actions - Actions which we are validating. - * - * @example - * - * const decision = await access.checkResource( - * { - * kind: "document", - * id: "1", - * attr: { owner: "user@example.com" }, - * }, - * ["view", "edit"], - * ); - * - * decision.isAllowed("view"); // => true - */ - checkResource: async (resource: Resource, actions: string[]) => { - const response = await access.checkResource({ body: { resource, actions } }); - if ("error" in response) { - throw response.error; - } - return new CheckResourcesResponse(response.data); - }, - - /** - * Check a principal's permissions on a set of resources. - * - * @param resources - Resources which we are validating. - * - * @example - * - * const decision = await access.checkResources([ - * { - * resource: { - * kind: "document", - * id: "1", - * attr: { owner: "user@example.com" }, - * }, - * actions: ["view", "edit"], - * }, - * { - * resource: { - * kind: "image", - * id: "1", - * attr: { owner: "user@example.com" }, - * }, - * actions: ["delete"], - * }, - * ]); - * - * decision.isAllowed({ - * resource: { kind: "document", id: "1" }, - * action: "view", - * }); // => true - */ - checkResources: async (resources: { resource: Resource; actions: string[] }[]) => { - const response = await access.checkResources({ body: resources }); - if ("error" in response) { - throw response.error; - } - return new CheckResourcesResponse(response.data); - }, - }, - }, -); - -type Resource = { - kind: string; - id: string; - attr: Record; -}; diff --git a/modules/iam/config.ts b/modules/iam/config.ts deleted file mode 100644 index 1fe9c30..0000000 --- a/modules/iam/config.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { getEnvironmentVariable } from "@platform/config/environment.ts"; -import type { SerializeOptions } from "cookie"; -import z from "zod"; - -export const config = { - url: getEnvironmentVariable({ - key: "IDENTITY_SERVICE_URL", - type: z.url(), - fallback: "http://localhost:8370", - }), - internal: { - privateKey: getEnvironmentVariable({ - key: "IDENTITY_PRIVATE_KEY", - type: z.string(), - fallback: - "-----BEGIN PRIVATE KEY-----\n" + - "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg2WYKMJZUWff5XOWC\n" + - "XGuU+wmsRzhQGEIzfUoL6rrGoaehRANCAATCpiGiFQxTA76EIVG0cBbj+AFt6BuJ\n" + - "t4q+zoInPUzkChCdwI+XfAYokrZwBjcyRGluC02HaN3cptrmjYSGSMSx\n" + - "-----END PRIVATE KEY-----", - }), - publicKey: getEnvironmentVariable({ - key: "IDENTITY_PUBLIC_KEY", - type: z.string(), - fallback: - "-----BEGIN PUBLIC KEY-----\n" + - "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEwqYhohUMUwO+hCFRtHAW4/gBbegb\n" + - "ibeKvs6CJz1M5AoQncCPl3wGKJK2cAY3MkRpbgtNh2jd3Kba5o2EhkjEsQ==\n" + - "-----END PUBLIC KEY-----", - }), - }, - cookie: (maxAge: number) => - ({ - httpOnly: true, - secure: getEnvironmentVariable({ - key: "AUTH_COOKIE_SECURE", - type: z.coerce.boolean(), - fallback: "false", - }), // Set to true for HTTPS in production - maxAge, - path: "/", - sameSite: "strict", - }) satisfies SerializeOptions, -}; diff --git a/modules/iam/models/principal.ts b/modules/iam/models/principal.ts deleted file mode 100644 index c0884f2..0000000 --- a/modules/iam/models/principal.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { makeDocumentParser } from "@platform/database/utilities.ts"; -import z from "zod"; - -export enum PrincipalTypeId { - User = 1, - Group = 2, - Other = 99, -} - -export const PRINCIPAL_TYPE_NAMES = { - [PrincipalTypeId.User]: "User", - [PrincipalTypeId.Group]: "Group", - [PrincipalTypeId.Other]: "Other", -}; - -/* - |-------------------------------------------------------------------------------- - | Schema - |-------------------------------------------------------------------------------- - */ - -export const PrincipalSchema = z.object({ - id: z.string(), - type: z.strictObject({ - id: z.enum(PrincipalTypeId), - name: z.string(), - }), - roles: z.array(z.string()), - attr: z.record(z.string(), z.any()), -}); - -/* - |-------------------------------------------------------------------------------- - | Parsers - |-------------------------------------------------------------------------------- - */ - -export const parsePrincipal = makeDocumentParser(PrincipalSchema); - -/* - |-------------------------------------------------------------------------------- - | Types - |-------------------------------------------------------------------------------- - */ - -export type Principal = z.infer; diff --git a/modules/iam/models/session.ts b/modules/iam/models/session.ts deleted file mode 100644 index 5fdc4f6..0000000 --- a/modules/iam/models/session.ts +++ /dev/null @@ -1,26 +0,0 @@ -import z from "zod"; - -/* - |-------------------------------------------------------------------------------- - | Schema - |-------------------------------------------------------------------------------- - */ - -export const SessionSchema = z.object({ - id: z.string(), - userId: z.string(), - token: z.string(), - ipAddress: z.string().nullable().optional(), - userAgent: z.string().nullable().optional(), - createdAt: z.coerce.date(), - updatedAt: z.coerce.date(), - expiresAt: z.coerce.date(), -}); - -/* - |-------------------------------------------------------------------------------- - | Types - |-------------------------------------------------------------------------------- - */ - -export type Session = z.infer; diff --git a/modules/iam/package.json b/modules/iam/package.json deleted file mode 100644 index a6565ed..0000000 --- a/modules/iam/package.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "name": "@modules/identity", - "version": "0.0.0", - "private": true, - "type": "module", - "exports": { - "./client.ts": "./client.ts", - "./server.ts": "./server.ts", - "./types.ts": "./types.ts" - }, - "dependencies": { - "@cerbos/core": "0.24.1", - "@cerbos/http": "0.23.1", - "@platform/config": "workspace:*", - "@platform/logger": "workspace:*", - "@platform/relay": "workspace:*", - "@platform/storage": "workspace:*", - "@platform/vault": "workspace:*", - "better-auth": "1.3.16", - "cookie": "1.0.2", - "zod": "4.1.11" - } -} diff --git a/modules/iam/routes/access/check-resource/handle.ts b/modules/iam/routes/access/check-resource/handle.ts deleted file mode 100644 index 4d67f6c..0000000 --- a/modules/iam/routes/access/check-resource/handle.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { cerbos } from "../../../cerbos/client.ts"; -import route from "./spec.ts"; - -export default route.access("session").handle(async ({ body: { resource, actions } }, { principal }) => { - return cerbos.checkResource({ principal, resource, actions }); -}); diff --git a/modules/iam/routes/access/check-resource/spec.ts b/modules/iam/routes/access/check-resource/spec.ts deleted file mode 100644 index 3984730..0000000 --- a/modules/iam/routes/access/check-resource/spec.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { route } from "@platform/relay"; -import z from "zod"; - -export default route - .post("/api/v1/identity/access/check-resource") - .body( - z.strictObject({ - resource: z.strictObject({ - kind: z.string(), - id: z.string(), - attr: z.record(z.string(), z.any()), - }), - actions: z.array(z.string()), - }), - ) - .response(z.any()); diff --git a/modules/iam/routes/access/check-resources/handle.ts b/modules/iam/routes/access/check-resources/handle.ts deleted file mode 100644 index 2d0d5d1..0000000 --- a/modules/iam/routes/access/check-resources/handle.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { cerbos } from "../../../cerbos/client.ts"; -import route from "./spec.ts"; - -export default route.access("session").handle(async ({ body: resources }, { principal }) => { - return cerbos.checkResources({ principal, resources }); -}); diff --git a/modules/iam/routes/access/check-resources/spec.ts b/modules/iam/routes/access/check-resources/spec.ts deleted file mode 100644 index 60e1d90..0000000 --- a/modules/iam/routes/access/check-resources/spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { route } from "@platform/relay"; -import z from "zod"; - -export default route - .post("/api/v1/identity/access/check-resources") - .body( - z.array( - z.strictObject({ - resource: z.strictObject({ - kind: z.string(), - id: z.string(), - attr: z.record(z.string(), z.any()), - }), - actions: z.array(z.string()), - }), - ), - ) - .response(z.any()); diff --git a/modules/iam/routes/access/is-allowed/handle.ts b/modules/iam/routes/access/is-allowed/handle.ts deleted file mode 100644 index c67af9c..0000000 --- a/modules/iam/routes/access/is-allowed/handle.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { cerbos } from "../../../cerbos/client.ts"; -import route from "./spec.ts"; - -export default route.access("session").handle(async ({ body: { resource, action } }, { principal }) => { - return cerbos.isAllowed({ principal, resource, action }); -}); diff --git a/modules/iam/routes/access/is-allowed/spec.ts b/modules/iam/routes/access/is-allowed/spec.ts deleted file mode 100644 index 8728df5..0000000 --- a/modules/iam/routes/access/is-allowed/spec.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { route } from "@platform/relay"; -import z from "zod"; - -export default route - .post("/api/v1/identity/access/is-allowed") - .body( - z.strictObject({ - resource: z.strictObject({ - kind: z.string(), - id: z.string(), - attr: z.record(z.string(), z.any()), - }), - action: z.string(), - }), - ) - .response(z.boolean()); diff --git a/modules/iam/routes/identities/get/handle.ts b/modules/iam/routes/identities/get/handle.ts deleted file mode 100644 index a0d09f2..0000000 --- a/modules/iam/routes/identities/get/handle.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { ForbiddenError, NotFoundError } from "@platform/relay"; - -import { getPrincipalById } from "../../../services/database.ts"; -import route from "./spec.ts"; - -export default route.access("session").handle(async ({ params: { id } }, { access }) => { - const principal = await getPrincipalById(id); - if (principal === undefined) { - return new NotFoundError("Identity does not exist, or has been removed."); - } - const decision = await access.isAllowed({ kind: "identity", id, attr: {} }, "read"); - if (decision === false) { - return new ForbiddenError("You do not have permission to view this identity."); - } - return principal; -}); diff --git a/modules/iam/routes/identities/get/spec.ts b/modules/iam/routes/identities/get/spec.ts deleted file mode 100644 index 828389e..0000000 --- a/modules/iam/routes/identities/get/spec.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { ForbiddenError, NotFoundError, route, UnauthorizedError } from "@platform/relay"; -import z from "zod"; - -export default route - .get("/api/v1/identity/:id") - .params({ - id: z.string(), - }) - .errors([UnauthorizedError, ForbiddenError, NotFoundError]) - .response(z.any()); diff --git a/modules/iam/routes/identities/update/handle.ts b/modules/iam/routes/identities/update/handle.ts deleted file mode 100644 index bb21965..0000000 --- a/modules/iam/routes/identities/update/handle.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { ForbiddenError, NotFoundError } from "@platform/relay"; - -import { getPrincipalById, setPrincipalAttributesById } from "../../../services/database.ts"; -import route from "./spec.ts"; - -export default route.access("session").handle(async ({ params: { id }, body: ops }, { access }) => { - const principal = await getPrincipalById(id); - if (principal === undefined) { - return new NotFoundError(); - } - const decision = await access.isAllowed({ kind: "identity", id: principal.id, attr: principal.attr }, "update"); - if (decision === false) { - return new ForbiddenError("You do not have permission to update this identity."); - } - const attr = principal.attr; - for (const op of ops) { - switch (op.type) { - case "add": { - attr[op.key] = op.value; - break; - } - case "push": { - if (attr[op.key] === undefined) { - attr[op.key] = op.values; - } else { - attr[op.key] = [...attr[op.key], ...op.values]; - } - break; - } - case "pop": { - if (Array.isArray(attr[op.key])) { - attr[op.key] = attr[op.key].filter((value: any) => op.values.includes(value) === false); - } - break; - } - case "remove": { - delete attr[op.key]; - break; - } - } - } - await setPrincipalAttributesById(id, attr); -}); diff --git a/modules/iam/routes/identities/update/spec.ts b/modules/iam/routes/identities/update/spec.ts deleted file mode 100644 index 9d3a0f2..0000000 --- a/modules/iam/routes/identities/update/spec.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { ForbiddenError, NotFoundError, route, UnauthorizedError } from "@platform/relay"; -import z from "zod"; - -export default route - .put("/api/v1/identity/:id") - .params({ - id: z.string(), - }) - .body( - z.array( - z.union([ - z.strictObject({ - type: z.union([z.literal("add")]), - key: z.string(), - value: z.any(), - }), - z.strictObject({ - type: z.union([z.literal("push"), z.literal("pop")]), - key: z.string(), - values: z.array(z.any()), - }), - z.strictObject({ - type: z.union([z.literal("remove")]), - key: z.string(), - }), - ]), - ), - ) - .errors([UnauthorizedError, ForbiddenError, NotFoundError]); diff --git a/modules/iam/routes/login/code/handle.ts b/modules/iam/routes/login/code/handle.ts deleted file mode 100644 index a867fd1..0000000 --- a/modules/iam/routes/login/code/handle.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { NotFoundError } from "@platform/relay"; - -import { auth } from "../../../services/auth.ts"; -import { logger } from "../../../services/logger.ts"; -import route from "./spec.ts"; - -export default route.access("public").handle(async ({ body: { email, otp } }) => { - const response = await auth.api.signInEmailOTP({ body: { email, otp }, asResponse: true, returnHeaders: true }); - if (response.status !== 200) { - logger.error("OTP Signin Failed", await response.json()); - return new NotFoundError(); - } - return new Response(null, { - headers: response.headers, - }); -}); diff --git a/modules/iam/routes/login/code/spec.ts b/modules/iam/routes/login/code/spec.ts deleted file mode 100644 index fdbafc4..0000000 --- a/modules/iam/routes/login/code/spec.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { route } from "@platform/relay"; -import z from "zod"; - -export default route - .post("/api/v1/identity/login/code") - .body( - z.strictObject({ - email: z.string(), - otp: z.string(), - }), - ) - .query({ - next: z.string().optional(), - }); diff --git a/modules/iam/routes/login/email/handle.ts b/modules/iam/routes/login/email/handle.ts deleted file mode 100644 index 2af0aa3..0000000 --- a/modules/iam/routes/login/email/handle.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { auth } from "../../../services/auth.ts"; -import { logger } from "../../../services/logger.ts"; -import route from "./spec.ts"; - -export default route.access("public").handle(async ({ body: { email } }) => { - const response = await auth.api.sendVerificationOTP({ body: { email, type: "sign-in" } }); - if (response.success === false) { - logger.info({ - type: "auth:passwordless", - message: "OTP Email verification failed.", - received: email, - }); - } -}); diff --git a/modules/iam/routes/login/email/spec.ts b/modules/iam/routes/login/email/spec.ts deleted file mode 100644 index a075483..0000000 --- a/modules/iam/routes/login/email/spec.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { route } from "@platform/relay"; -import z from "zod"; - -export default route.post("/api/v1/identity/login/email").body( - z.object({ - email: z.email(), - }), -); diff --git a/modules/iam/routes/login/password/handle.ts b/modules/iam/routes/login/password/handle.ts deleted file mode 100644 index 978c264..0000000 --- a/modules/iam/routes/login/password/handle.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { logger } from "@platform/logger"; -import { BadRequestError } from "@platform/relay"; -import cookie from "cookie"; - -import { auth } from "../../../auth.ts"; -import { config } from "../../../config.ts"; -import { password } from "../../../crypto/password.ts"; -import { getPasswordStrategyByAlias } from "../../../database.ts"; -import route from "./spec.ts"; - -export default route.access("public").handle(async ({ body: { alias, password: userPassword } }) => { - const strategy = await getPasswordStrategyByAlias(alias); - if (strategy === undefined) { - return logger.info({ - type: "auth:password", - message: "Failed to get account with 'password' strategy.", - alias, - }); - } - - const isValidPassword = await password.verify(userPassword, strategy.password); - if (isValidPassword === false) { - return new BadRequestError("Invalid email/password provided."); - } - - return new Response(null, { - status: 204, - headers: { - "set-cookie": cookie.serialize( - "token", - await auth.generate({ id: strategy.accountId }, "1 week"), - config.cookie(1000 * 60 * 60 * 24 * 7), - ), - }, - }); -}); diff --git a/modules/iam/routes/login/password/spec.ts b/modules/iam/routes/login/password/spec.ts deleted file mode 100644 index 48a42d7..0000000 --- a/modules/iam/routes/login/password/spec.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { route } from "@platform/relay"; -import z from "zod"; - -export default route.post("/api/v1/identities/login/password").body( - z.object({ - alias: z.string(), - password: z.string(), - }), -); diff --git a/modules/iam/routes/login/sudo/handle.ts b/modules/iam/routes/login/sudo/handle.ts deleted file mode 100644 index 7478252..0000000 --- a/modules/iam/routes/login/sudo/handle.ts +++ /dev/null @@ -1,39 +0,0 @@ -import route from "./spec.ts"; - -export default route.access("public").handle(async () => { - // const code = await Passwordless.createCode({ tenantId: "public", email }); - // if (code.status !== "OK") { - // return logger.info({ - // type: "auth:passwordless", - // message: "Create code failed.", - // received: email, - // }); - // } - // logger.info({ - // type: "auth:passwordless", - // data: { - // deviceId: code.deviceId, - // preAuthSessionId: code.preAuthSessionId, - // userInputCode: code.userInputCode, - // }, - // }); - // const response = await Passwordless.consumeCode({ - // tenantId: "public", - // preAuthSessionId: code.preAuthSessionId, - // deviceId: code.deviceId, - // userInputCode: code.userInputCode, - // }); - // if (response.status !== "OK") { - // return new NotFoundError(); - // } - // logger.info({ - // type: "code:claimed", - // session: true, - // message: "Identity resolved", - // user: response.user.toJson(), - // }); - // return new Response(null, { - // status: 200, - // headers: await getSessionHeaders("public", response.recipeUserId), - // }); -}); diff --git a/modules/iam/routes/login/sudo/spec.ts b/modules/iam/routes/login/sudo/spec.ts deleted file mode 100644 index b8fc493..0000000 --- a/modules/iam/routes/login/sudo/spec.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { route } from "@platform/relay"; -import z from "zod"; - -export default route.post("/api/v1/identities/login/sudo").body( - z.object({ - email: z.email(), - }), -); diff --git a/modules/iam/routes/me/handle.ts b/modules/iam/routes/me/handle.ts deleted file mode 100644 index ef1e7ee..0000000 --- a/modules/iam/routes/me/handle.ts +++ /dev/null @@ -1,5 +0,0 @@ -import route from "./spec.ts"; - -export default route.access("session").handle(async ({ principal }) => { - return principal; -}); diff --git a/modules/iam/routes/me/spec.ts b/modules/iam/routes/me/spec.ts deleted file mode 100644 index 6c2067d..0000000 --- a/modules/iam/routes/me/spec.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { NotFoundError, route, UnauthorizedError } from "@platform/relay"; -import z from "zod"; - -export default route.get("/api/v1/identity/me").errors([UnauthorizedError, NotFoundError]).response(z.any()); diff --git a/modules/iam/routes/roles/handle.ts b/modules/iam/routes/roles/handle.ts deleted file mode 100644 index 51a097a..0000000 --- a/modules/iam/routes/roles/handle.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { ForbiddenError, NotFoundError } from "@platform/relay"; - -import { getPrincipalById, setPrincipalRolesById } from "../../services/database.ts"; -import route from "./spec.ts"; - -export default route.access("session").handle(async ({ params: { id }, body: ops }, { access }) => { - const principal = await getPrincipalById(id); - if (principal === undefined) { - return new NotFoundError(); - } - const decision = await access.isAllowed({ kind: "role", id: principal.id, attr: principal.attr }, "manage"); - if (decision === false) { - return new ForbiddenError("You do not have permission to modify roles for this identity."); - } - const roles: Set = new Set(principal.roles); - for (const op of ops) { - switch (op.type) { - case "add": { - for (const role of op.roles) { - roles.add(role); - } - break; - } - case "remove": { - for (const role of op.roles) { - roles.delete(role); - } - break; - } - } - } - await setPrincipalRolesById(id, Array.from(roles)); -}); diff --git a/modules/iam/routes/roles/spec.ts b/modules/iam/routes/roles/spec.ts deleted file mode 100644 index d159b0c..0000000 --- a/modules/iam/routes/roles/spec.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { ForbiddenError, NotFoundError, route, UnauthorizedError } from "@platform/relay"; -import z from "zod"; - -export default route - .put("/api/v1/identity/:id/roles") - .params({ - id: z.string(), - }) - .body( - z.array( - z.union([ - z.strictObject({ - type: z.union([z.literal("add"), z.literal("remove")]), - roles: z.array(z.any()), - }), - ]), - ), - ) - .errors([UnauthorizedError, ForbiddenError, NotFoundError]); diff --git a/modules/iam/routes/session/resolve/handle.ts b/modules/iam/routes/session/resolve/handle.ts deleted file mode 100644 index d431062..0000000 --- a/modules/iam/routes/session/resolve/handle.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { NotFoundError } from "@platform/relay"; - -import { config } from "../../../config.ts"; -import { getPrincipalByUserId } from "../../../services/database.ts"; -import { getSessionByRequestHeader } from "../../../services/session.ts"; -import route from "./spec.ts"; - -export default route.access(["internal:public", config.internal.privateKey]).handle(async ({ request }) => { - const session = await getSessionByRequestHeader(request.headers); - if (session === undefined) { - return new NotFoundError(); - } - return { - session, - principal: await getPrincipalByUserId(session.userId), - }; -}); diff --git a/modules/iam/routes/session/resolve/spec.ts b/modules/iam/routes/session/resolve/spec.ts deleted file mode 100644 index 01a748f..0000000 --- a/modules/iam/routes/session/resolve/spec.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { route } from "@platform/relay"; -import z from "zod"; - -import { PrincipalSchema } from "../../../models/principal.ts"; -import { SessionSchema } from "../../../models/session.ts"; - -export default route.get("/api/v1/identity/session").response( - z.object({ - session: SessionSchema, - principal: PrincipalSchema, - }), -); diff --git a/modules/iam/server.ts b/modules/iam/server.ts deleted file mode 100644 index 7f50a6d..0000000 --- a/modules/iam/server.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { HttpAdapter, makeClient } from "@platform/relay"; - -import { config } from "./config.ts"; -import resolve from "./routes/session/resolve/spec.ts"; - -/* - |-------------------------------------------------------------------------------- - | Internal Session Resolver - |-------------------------------------------------------------------------------- - */ - -const identity = makeClient( - { - adapter: new HttpAdapter({ - url: config.url, - }), - }, - { - resolve: resolve.crypto({ - publicKey: config.internal.publicKey, - }), - }, -); - -export async function getPrincipalSession(payload: { headers: Headers }) { - const response = await identity.resolve(payload); - if ("data" in response) { - return response.data; - } -} - -/* - |-------------------------------------------------------------------------------- - | Server Exports - |-------------------------------------------------------------------------------- - */ - -export * from "./services/session.ts"; -export * from "./types.ts"; - -/* - |-------------------------------------------------------------------------------- - | Module Server - |-------------------------------------------------------------------------------- - */ - -export default { - routes: [ - (await import("./routes/identities/get/handle.ts")).default, - (await import("./routes/identities/update/handle.ts")).default, - (await import("./routes/login/code/handle.ts")).default, - (await import("./routes/login/email/handle.ts")).default, - // (await import("./routes/login/password/handle.ts")).default, - (await import("./routes/login/sudo/handle.ts")).default, - (await import("./routes/me/handle.ts")).default, - (await import("./routes/roles/handle.ts")).default, - (await import("./routes/session/resolve/handle.ts")).default, - (await import("./routes/access/is-allowed/handle.ts")).default, - (await import("./routes/access/check-resource/handle.ts")).default, - (await import("./routes/access/check-resources/handle.ts")).default, - ], -}; diff --git a/modules/iam/services/auth.ts b/modules/iam/services/auth.ts deleted file mode 100644 index 4f243c0..0000000 --- a/modules/iam/services/auth.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { logger } from "@platform/logger"; -import { betterAuth } from "better-auth"; -import { mongodbAdapter } from "better-auth/adapters/mongodb"; -import { emailOTP } from "better-auth/plugins"; - -import { db } from "./database.ts"; - -export const auth = betterAuth({ - database: mongodbAdapter(db.db), - session: { - cookieCache: { - enabled: true, - maxAge: 5 * 60, // Cache duration in seconds - }, - }, - plugins: [ - emailOTP({ - async sendVerificationOTP({ email, otp, type }) { - if (type === "sign-in") { - logger.info({ email, otp, type }); - } else if (type === "email-verification") { - // Send the OTP for email verification - } else { - // Send the OTP for password reset - } - }, - }), - ], -}); diff --git a/modules/iam/services/database.ts b/modules/iam/services/database.ts deleted file mode 100644 index d729510..0000000 --- a/modules/iam/services/database.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { getDatabaseAccessor } from "@platform/database/accessor.ts"; - -import { - PRINCIPAL_TYPE_NAMES, - type Principal, - PrincipalSchema, - PrincipalTypeId, - parsePrincipal, -} from "../models/principal.ts"; - -export const db = getDatabaseAccessor<{ - principal: Principal; -}>("auth"); - -/* - |-------------------------------------------------------------------------------- - | Methods - |-------------------------------------------------------------------------------- - */ - -export async function getPrincipalById(id: string): Promise { - return db - .collection("principal") - .findOne({ id }) - .then((value) => parsePrincipal(value)); -} - -export async function setPrincipalRolesById(id: string, roles: string[]): Promise { - await db.collection("principal").updateOne({ id }, { $set: { roles } }); -} - -export async function setPrincipalAttributesById(id: string, attr: Record): Promise { - await db.collection("principal").updateOne({ id }, { $set: { attr } }); -} - -/** - * Retrieve a principal for a better-auth user. - * - * @param userId - User id from better-auth user list. - */ -export async function getPrincipalByUserId(userId: string): Promise { - const principal = await db.collection("principal").findOneAndUpdate( - { id: userId }, - { - $setOnInsert: { - id: userId, - type: { - id: PrincipalTypeId.User, - name: PRINCIPAL_TYPE_NAMES[PrincipalTypeId.User], - }, - roles: ["user"], - attr: {}, - }, - }, - { upsert: true, returnDocument: "after" }, - ); - if (principal === null) { - throw new Error("Failed to resolve Principal"); - } - return PrincipalSchema.parse(principal); -} diff --git a/modules/iam/services/logger.ts b/modules/iam/services/logger.ts deleted file mode 100644 index 965cd3d..0000000 --- a/modules/iam/services/logger.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { logger as platformLogger } from "@platform/logger"; - -export const logger = platformLogger.prefix("Modules/Identity"); diff --git a/modules/iam/services/session.ts b/modules/iam/services/session.ts deleted file mode 100644 index 7a1e806..0000000 --- a/modules/iam/services/session.ts +++ /dev/null @@ -1,34 +0,0 @@ -import cookie from "cookie"; - -import { config } from "../config.ts"; -import { auth } from "./auth.ts"; - -/** - * Get session headers which can be applied on a Response object to apply - * an authenticated session to the respondent. - * - * @param accessToken - Token to apply to the cookie. - * @param maxAge - Max age of the token. - */ -export async function getSessionHeaders(accessToken: string, maxAge: number): Promise { - return new Headers({ - "set-cookie": cookie.serialize( - "better-auth.session_token", - encodeURIComponent(accessToken), // URL-encode the token - config.cookie(maxAge), - ), - }); -} - -/** - * Get session container from request headers. - * - * @param headers - Request headers to extract session from. - */ -export async function getSessionByRequestHeader(headers: Headers) { - const response = await auth.api.getSession({ headers }); - if (response === null) { - return undefined; - } - return response.session; -} diff --git a/modules/iam/types.ts b/modules/iam/types.ts deleted file mode 100644 index df4fd79..0000000 --- a/modules/iam/types.ts +++ /dev/null @@ -1,50 +0,0 @@ -import "@platform/relay"; -import "@platform/storage"; - -import type { Session } from "better-auth"; - -import type { identity } from "./client.ts"; -import type { Principal } from "./models/principal.ts"; - -declare module "@platform/storage" { - interface StorageContext { - /** - * TODO ... - */ - session?: Session; - - /** - * TODO ... - */ - principal?: Principal; - - /** - * TODO ... - */ - access?: typeof identity.access; - } -} - -declare module "@platform/relay" { - interface ServerContext { - /** - * TODO ... - */ - isAuthenticated: boolean; - - /** - * TODO ... - */ - session: Session; - - /** - * TODO ... - */ - principal: Principal; - - /** - * TODO ... - */ - access: typeof identity.access; - } -} diff --git a/modules/tenant/package.json b/modules/tenant/package.json new file mode 100644 index 0000000..627d3fb --- /dev/null +++ b/modules/tenant/package.json @@ -0,0 +1,6 @@ +{ + "name": "@module/tenant", + "version": "0.0.0", + "private": true, + "type": "module" +} diff --git a/modules/workspace/aggregates/workspace-user.ts b/modules/workspace/aggregates/workspace-user.ts deleted file mode 100644 index 7f29851..0000000 --- a/modules/workspace/aggregates/workspace-user.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { type AuditActor, auditors } from "@platform/spec/audit/actor.ts"; -import { AggregateRoot, getDate } from "@valkyr/event-store"; - -import { db } from "../database.ts"; -import { type EventRecord, type EventStoreFactory, projector } from "../event-store.ts"; - -export class WorkspaceUser extends AggregateRoot { - static override readonly name = "workspace:user"; - - workspaceId!: string; - identityId!: string; - - createdAt!: Date; - updatedAt?: Date; - - // ------------------------------------------------------------------------- - // Reducer - // ------------------------------------------------------------------------- - - with(event: EventRecord): void { - switch (event.type) { - case "workspace:user:created": { - this.workspaceId = event.data.workspaceId; - this.identityId = event.data.identityId; - break; - } - } - } - - // ------------------------------------------------------------------------- - // Actions - // ------------------------------------------------------------------------- - - create(workspaceId: string, identityId: string, meta: AuditActor = auditors.system) { - return this.push({ - stream: this.id, - type: "workspace:user:created", - data: { - workspaceId, - identityId, - }, - meta, - }); - } -} - -/* - |-------------------------------------------------------------------------------- - | Projectors - |-------------------------------------------------------------------------------- - */ - -projector.on("workspace:user:created", async ({ stream: id, data: { workspaceId, identityId }, meta, created }) => { - await db.collection("workspace:users").insertOne({ - id, - workspaceId, - identityId, - name: { - given: "", - family: "", - }, - contacts: [], - createdAt: getDate(created), - createdBy: meta.user.uid ?? "Unknown", - }); -}); diff --git a/modules/workspace/aggregates/workspace.ts b/modules/workspace/aggregates/workspace.ts deleted file mode 100644 index c76685d..0000000 --- a/modules/workspace/aggregates/workspace.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { type AuditActor, auditors } from "@platform/spec/audit/actor.ts"; -import { AggregateRoot, getDate } from "@valkyr/event-store"; - -import { db } from "../database.ts"; -import { type EventRecord, type EventStoreFactory, projector } from "../event-store.ts"; - -export class Workspace extends AggregateRoot { - static override readonly name = "workspace"; - - ownerId!: string; - - name!: string; - description?: string; - archived = false; - - createdAt!: Date; - updatedAt?: Date; - - // ------------------------------------------------------------------------- - // Reducer - // ------------------------------------------------------------------------- - - with(event: EventRecord): void { - switch (event.type) { - case "workspace:created": { - this.id = event.stream; - this.ownerId = event.data.ownerId; - this.name = event.data.name; - this.createdAt = getDate(event.created); - break; - } - case "workspace:name:added": { - this.name = event.data; - this.updatedAt = getDate(event.created); - break; - } - case "workspace:description:added": { - this.description = event.data; - this.updatedAt = getDate(event.created); - break; - } - case "workspace:archived": { - this.archived = true; - this.updatedAt = getDate(event.created); - break; - } - case "workspace:restored": { - this.archived = false; - this.updatedAt = getDate(event.created); - break; - } - } - } - - // ------------------------------------------------------------------------- - // Actions - // ------------------------------------------------------------------------- - - create(ownerId: string, name: string, meta: AuditActor = auditors.system) { - return this.push({ - stream: this.id, - type: "workspace:created", - data: { - ownerId, - name, - }, - meta, - }); - } - - setName(name: string, meta: AuditActor = auditors.system) { - return this.push({ - stream: this.id, - type: "workspace:name:added", - data: name, - meta, - }); - } - - setDescription(description: string, meta: AuditActor = auditors.system) { - return this.push({ - stream: this.id, - type: "workspace:description:added", - data: description, - meta, - }); - } - - archive(meta: AuditActor = auditors.system) { - return this.push({ - stream: this.id, - type: "workspace:archived", - meta, - }); - } - - restore(meta: AuditActor = auditors.system) { - return this.push({ - stream: this.id, - type: "workspace:restored", - meta, - }); - } -} - -/* - |-------------------------------------------------------------------------------- - | Projectors - |-------------------------------------------------------------------------------- - */ - -projector.on("workspace:created", async ({ stream: id, data: { ownerId, name }, meta, created }) => { - await db.collection("workspaces").insertOne({ - id, - ownerId, - name, - createdAt: getDate(created), - createdBy: meta.user.uid ?? "Unknown", - }); -}); diff --git a/modules/workspace/database.ts b/modules/workspace/database.ts deleted file mode 100644 index 1448cb2..0000000 --- a/modules/workspace/database.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { getDatabaseAccessor } from "@platform/database/accessor.ts"; - -import { parseWorkspace, type Workspace } from "./models/workspace.ts"; -import type { WorkspaceUser } from "./models/workspace-user.ts"; - -export const db = getDatabaseAccessor<{ - workspaces: Workspace; - "workspace:users": WorkspaceUser; -}>(`workspace:read-store`); - -/* - |-------------------------------------------------------------------------------- - | Identity - |-------------------------------------------------------------------------------- - */ - -/** - * Retrieve a single workspace by its primary identifier. - * - * @param id - Unique identity. - */ -export async function getWorkspaceById(id: string): Promise { - return db - .collection("workspaces") - .findOne({ id }) - .then((document) => parseWorkspace(document)); -} diff --git a/modules/workspace/event-store.ts b/modules/workspace/event-store.ts deleted file mode 100644 index 1c184d8..0000000 --- a/modules/workspace/event-store.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { mongo } from "@platform/database/client.ts"; -import { EventFactory, EventStore, type Prettify, Projector } from "@valkyr/event-store"; -import { MongoAdapter } from "@valkyr/event-store/mongo"; - -/* - |-------------------------------------------------------------------------------- - | Event Factory - |-------------------------------------------------------------------------------- - */ - -const eventFactory = new EventFactory([ - ...(await import("./events/workspace.ts")).default, - ...(await import("./events/workspace-user.ts")).default, -]); - -/* - |-------------------------------------------------------------------------------- - | Event Store - |-------------------------------------------------------------------------------- - */ - -export const eventStore = new EventStore({ - adapter: new MongoAdapter(() => mongo, `workspace:event-store`), - events: eventFactory, - snapshot: "auto", -}); - -/* - |-------------------------------------------------------------------------------- - | Projector - |-------------------------------------------------------------------------------- - */ - -export const projector = new Projector(); - -eventStore.onEventsInserted(async (records, { batch }) => { - if (batch !== undefined) { - await projector.pushMany(batch, records); - } else { - for (const record of records) { - await projector.push(record, { hydrated: false, outdated: false }); - } - } -}); - -/* - |-------------------------------------------------------------------------------- - | Events - |-------------------------------------------------------------------------------- - */ - -export type EventStoreFactory = typeof eventFactory; - -export type EventRecord = Prettify; diff --git a/modules/workspace/events/workspace-user.ts b/modules/workspace/events/workspace-user.ts deleted file mode 100644 index 225c7cf..0000000 --- a/modules/workspace/events/workspace-user.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { AuditActorSchema } from "@platform/spec/audit/actor.ts"; -import { event } from "@valkyr/event-store"; -import z from "zod"; - -import { AvatarSchema } from "../value-objects/avatar.ts"; -import { ContactSchema } from "../value-objects/contact.ts"; -import { NameSchema } from "../value-objects/name.ts"; - -export default [ - event - .type("workspace:user:created") - .data( - z.strictObject({ - workspaceId: z.string(), - identityId: z.string(), - }), - ) - .meta(AuditActorSchema), - event.type("workspace:user:name-set").data(NameSchema).meta(AuditActorSchema), - event.type("workspace:user:avatar-set").data(AvatarSchema).meta(AuditActorSchema), - event.type("workspace:user:contacts-added").data(z.array(ContactSchema)).meta(AuditActorSchema), - event.type("workspace:user:contacts-removed").data(z.array(z.string())).meta(AuditActorSchema), -]; diff --git a/modules/workspace/events/workspace.ts b/modules/workspace/events/workspace.ts deleted file mode 100644 index 7c5d984..0000000 --- a/modules/workspace/events/workspace.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { AuditActorSchema } from "@platform/spec/audit/actor.ts"; -import { event } from "@valkyr/event-store"; -import z from "zod"; - -export default [ - event - .type("workspace:created") - .data( - z.strictObject({ - ownerId: z.uuid(), - name: z.string(), - }), - ) - .meta(AuditActorSchema), - event.type("workspace:name:added").data(z.string()).meta(AuditActorSchema), - event.type("workspace:description:added").data(z.string()).meta(AuditActorSchema), - event.type("workspace:archived").meta(AuditActorSchema), - event.type("workspace:restored").meta(AuditActorSchema), -]; diff --git a/modules/workspace/models/workspace-user.ts b/modules/workspace/models/workspace-user.ts deleted file mode 100644 index 4d368b2..0000000 --- a/modules/workspace/models/workspace-user.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { makeDocumentParser } from "@platform/database/utilities.ts"; -import { z } from "zod"; - -import { AvatarSchema } from "../value-objects/avatar.ts"; -import { ContactSchema } from "../value-objects/contact.ts"; -import { NameSchema } from "../value-objects/name.ts"; - -export const WorkspaceUserSchema = z.object({ - id: z.uuid(), - - workspaceId: z.uuid(), - identityId: z.string(), - - name: NameSchema.optional(), - avatar: AvatarSchema.optional(), - contacts: z.array(ContactSchema).default([]), - - createdAt: z.coerce.date(), - createdBy: z.string(), - updatedAt: z.coerce.date().optional(), - updatedBy: z.string().optional(), -}); - -/* - |-------------------------------------------------------------------------------- - | Parsers - |-------------------------------------------------------------------------------- - */ - -export const parseWorkspaceUser = makeDocumentParser(WorkspaceUserSchema); - -/* - |-------------------------------------------------------------------------------- - | Types - |-------------------------------------------------------------------------------- - */ - -export type WorkspaceUser = z.infer; diff --git a/modules/workspace/models/workspace.ts b/modules/workspace/models/workspace.ts deleted file mode 100644 index 7240c52..0000000 --- a/modules/workspace/models/workspace.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { makeDocumentParser } from "@platform/database/utilities.ts"; -import { z } from "zod"; - -export const WorkspaceSchema = z.object({ - id: z.uuid(), - - ownerId: z.uuid(), - - name: z.string(), - description: z.string().optional(), - - createdAt: z.coerce.date(), - createdBy: z.string(), - updatedAt: z.coerce.date().optional(), - updatedBy: z.string().optional(), -}); - -/* - |-------------------------------------------------------------------------------- - | Parsers - |-------------------------------------------------------------------------------- - */ - -export const parseWorkspace = makeDocumentParser(WorkspaceSchema); - -/* - |-------------------------------------------------------------------------------- - | Types - |-------------------------------------------------------------------------------- - */ - -export type Workspace = z.infer; diff --git a/modules/workspace/package.json b/modules/workspace/package.json deleted file mode 100644 index 8f2c315..0000000 --- a/modules/workspace/package.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "name": "@modules/workspace", - "version": "0.0.0", - "private": true, - "type": "module", - "exports": { - "./client.ts": "./client.ts", - "./server.ts": "./server.ts" - }, - "types": "types.d.ts", - "dependencies": { - "@modules/iam": "workspace:*", - "@platform/database": "workspace:*", - "@platform/relay": "workspace:*", - "@platform/spec": "workspace:*", - "@valkyr/event-store": "npm:@jsr/valkyr__event-store@2.0.1", - "cookie": "1.0.2", - "zod": "4.1.11" - } -} diff --git a/modules/workspace/routes/workspaces/create/handle.ts b/modules/workspace/routes/workspaces/create/handle.ts deleted file mode 100644 index d01ec59..0000000 --- a/modules/workspace/routes/workspaces/create/handle.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { ForbiddenError } from "@platform/relay"; - -import { Workspace } from "../../../aggregates/workspace.ts"; -import { eventStore } from "../../../event-store.ts"; -import route from "./spec.ts"; - -export default route.access("session").handle(async ({ body: { name } }, { access, principal }) => { - const decision = await access.isAllowed({ kind: "workspace", id: "1", attr: {} }, "create"); - if (decision === false) { - return new ForbiddenError("You do not have permission to create workspaces."); - } - const workspace = await eventStore.aggregate.from(Workspace).create(principal.id, name).save(); - return { - id: workspace.id, - ownerId: workspace.ownerId, - name: workspace.name, - createdAt: workspace.createdAt, - createdBy: principal.id, - }; -}); diff --git a/modules/workspace/routes/workspaces/create/spec.ts b/modules/workspace/routes/workspaces/create/spec.ts deleted file mode 100644 index 2b4fd57..0000000 --- a/modules/workspace/routes/workspaces/create/spec.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { ForbiddenError, InternalServerError, route, UnauthorizedError, ValidationError } from "@platform/relay"; -import z from "zod"; - -import { WorkspaceSchema } from "../../../models/workspace.ts"; - -export default route - .post("/api/v1/workspace") - .body( - z.strictObject({ - name: z.string(), - }), - ) - .errors([UnauthorizedError, ForbiddenError, ValidationError, InternalServerError]) - .response(WorkspaceSchema); diff --git a/modules/workspace/server.ts b/modules/workspace/server.ts deleted file mode 100644 index 7da962c..0000000 --- a/modules/workspace/server.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { idIndex } from "@platform/database/id.ts"; -import { register as registerReadStore } from "@platform/database/registrar.ts"; -import { register as registerEventStore } from "@valkyr/event-store/mongo"; -import "@modules/iam/types.ts"; - -import { db } from "./database.ts"; -import { eventStore } from "./event-store.ts"; - -export default { - routes: [(await import("./routes/workspaces/create/handle.ts")).default], - - bootstrap: async (): Promise => { - await registerReadStore(db.db, [ - { - name: "workspaces", - indexes: [ - idIndex, - // [{ "strategies.type": 1, "strategies.alias": 1 }, { name: "strategy.password" }], - ], - }, - { - name: "workspace:users", - indexes: [ - idIndex, - // [{ "strategies.type": 1, "strategies.alias": 1 }, { name: "strategy.password" }], - ], - }, - ]); - await registerEventStore(eventStore.db.db, console.info); - }, -}; diff --git a/modules/workspace/value-objects/avatar.ts b/modules/workspace/value-objects/avatar.ts deleted file mode 100644 index 2a46586..0000000 --- a/modules/workspace/value-objects/avatar.ts +++ /dev/null @@ -1,7 +0,0 @@ -import z from "zod"; - -export const AvatarSchema = z.object({ - url: z.string().describe("A valid URL pointing to the user's avatar image."), -}); - -export type Avatar = z.infer; diff --git a/modules/workspace/value-objects/contact.ts b/modules/workspace/value-objects/contact.ts deleted file mode 100644 index 384d09f..0000000 --- a/modules/workspace/value-objects/contact.ts +++ /dev/null @@ -1,13 +0,0 @@ -import z from "zod"; - -import { EmailSchema } from "./email.ts"; - -export const ContactSchema = z.union([ - z.object({ - id: z.string(), - type: z.literal("email"), - email: EmailSchema, - }), -]); - -export type Contact = z.infer; diff --git a/modules/workspace/value-objects/email.ts b/modules/workspace/value-objects/email.ts deleted file mode 100644 index 4e313aa..0000000 --- a/modules/workspace/value-objects/email.ts +++ /dev/null @@ -1,11 +0,0 @@ -import z from "zod"; - -export const EmailSchema = z.object({ - type: z.enum(["personal", "work"]).describe("The context of the email address, e.g., personal or work."), - value: z.email().describe("A valid email address string."), - primary: z.boolean().describe("Indicates if this is the primary email."), - verified: z.boolean().describe("True if the email address has been verified."), - label: z.string().optional().describe("Optional display label for the email address."), -}); - -export type Email = z.infer; diff --git a/modules/workspace/value-objects/name.ts b/modules/workspace/value-objects/name.ts deleted file mode 100644 index 2a8bf12..0000000 --- a/modules/workspace/value-objects/name.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { z } from "zod"; - -export const NameSchema = z.object({ - family: z.string().nullable().describe("Family name, also known as last name or surname."), - given: z.string().nullable().describe("Given name, also known as first name."), -}); - -export type Name = z.infer; diff --git a/platform/config/mod.ts b/platform/config/mod.ts new file mode 100644 index 0000000..e538104 --- /dev/null +++ b/platform/config/mod.ts @@ -0,0 +1,4 @@ +export * from "./dotenv.ts"; +export * from "./environment.ts"; +export * from "./errors.ts"; +export * from "./service.ts"; diff --git a/platform/config/package.json b/platform/config/package.json index 64231d6..7688e55 100644 --- a/platform/config/package.json +++ b/platform/config/package.json @@ -3,8 +3,12 @@ "version": "0.0.0", "private": true, "type": "module", + "main": "./mod.ts", + "exports": { + ".": "./mod.ts" + }, "dependencies": { "@std/dotenv": "npm:@jsr/std__dotenv@0.225.5", - "zod": "4.1.11" + "zod": "4.1.12" } } diff --git a/platform/database/accessor.ts b/platform/database/accessor.ts deleted file mode 100644 index e58d20b..0000000 --- a/platform/database/accessor.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { Collection, CollectionOptions, Db, Document, MongoClient } from "mongodb"; - -import { mongo } from "./client.ts"; - -export function getDatabaseAccessor>( - database: string, -): DatabaseAccessor { - let instance: Db | undefined; - return { - get db(): Db { - if (instance === undefined) { - instance = this.client.db(database); - } - return instance; - }, - get client(): MongoClient { - return mongo; - }, - collection( - name: TSchema, - options?: CollectionOptions, - ): Collection { - return this.db.collection(name.toString(), options); - }, - }; -} - -export type DatabaseAccessor> = { - /** - * Database for given accessor. - */ - db: Db; - - /** - * Lazy loaded mongo client. - */ - client: MongoClient; - - /** - * Returns a reference to a MongoDB Collection. If it does not exist it will be created implicitly. - * - * Collection namespace validation is performed server-side. - * - * @param name - Collection name we wish to access. - * @param options - Optional settings for the command. - */ - collection(name: TSchema, options?: CollectionOptions): Collection; -}; diff --git a/platform/database/client.ts b/platform/database/client.ts index d8683f4..54c3ddb 100644 --- a/platform/database/client.ts +++ b/platform/database/client.ts @@ -1,4 +1,121 @@ -import { config } from "./config.ts"; -import { getMongoClient } from "./connection.ts"; +import { AsyncLocalStorage } from "node:async_hooks"; -export const mongo = getMongoClient(config.mongo); +import postgres, { type Options, type Sql, type TransactionSql } from "postgres"; +import type { ZodType } from "zod"; + +import { takeAll, takeOne } from "./parser.ts"; + +const storage = new AsyncLocalStorage(); + +/* + |-------------------------------------------------------------------------------- + | Database + |-------------------------------------------------------------------------------- + */ + +export class Client { + /** + * Cached SQL instance. + */ + #db?: Sql; + + /** + * Instantiate a new Database accessor wrapper. + * + * @param db - Dependency container token to retrieve. + */ + constructor(readonly config: Options<{}>) {} + + /** + * SQL instance to perform queries against. + */ + get sql(): Sql { + const tx = storage.getStore(); + if (tx !== undefined) { + return tx; + } + return this.#getResolvedInstance(); + } + + /** + * SQL instance which ignores any potential transaction established + * in instance scope. + */ + get direct(): Sql { + return this.#getResolvedInstance(); + } + + /** + * Retrieves cached SQL instance or attempts to create and return + * a new instance. + */ + #getResolvedInstance(): Sql { + if (this.#db === undefined) { + this.#db = postgres(this.config); + } + return this.#db; + } + + /** + * Initiates a SQL transaction by wrapping a new db instance with a + * new transaction instance. + * + * @example + * ```ts + * import { db } from "@optio/database/client.ts"; + * + * db.begin(async (tx) => { + * tx`SELECT ...` + * }); + * ``` + */ + begin(cb: (tx: TransactionSql) => TResponse | Promise): Promise> { + return this.direct.begin((tx) => storage.run(tx, () => cb(tx))); + } + + /** + * Closes SQL connection if it has been instantiated. + */ + async close(): Promise { + if (this.#db !== undefined) { + await this.#db.end(); + this.#db = undefined; + } + } + + /** + * Returns a schema pepared querying object allowing for a one or many + * response based on the query used. + * + * @param schema - Zod schema to parse. + */ + schema(schema: TSchema) { + return { + /** + * Executes a sql query and parses the result with the given schema. + * + * @param sql - Template string SQL value. + */ + one: (strings: TemplateStringsArray, ...values: any[]) => this.sql(strings, ...values).then(takeOne(schema)), + + /** + * Executes a sql query and parses the resulting list with the given schema. + * + * @param sql - Template string SQL value. + */ + many: (strings: TemplateStringsArray, ...values: any[]) => this.sql(strings, ...values).then(takeAll(schema)), + }; + } +} + +/* + |-------------------------------------------------------------------------------- + | Types + |-------------------------------------------------------------------------------- + */ + +type UnwrapPromiseArray = T extends any[] + ? { + [k in keyof T]: T[k] extends Promise ? R : T[k]; + } + : T; diff --git a/platform/database/config.ts b/platform/database/config.ts index 6113d59..250c17d 100644 --- a/platform/database/config.ts +++ b/platform/database/config.ts @@ -2,26 +2,26 @@ import { getEnvironmentVariable } from "@platform/config/environment.ts"; import z from "zod"; export const config = { - mongo: { + xtdb: { host: getEnvironmentVariable({ - key: "DB_MONGO_HOST", + key: "DB_XTDB_HOST", type: z.string(), fallback: "localhost", }), port: getEnvironmentVariable({ - key: "DB_MONGO_PORT", + key: "DB_XTDB_PORT", type: z.coerce.number(), - fallback: "67017", + fallback: "5432", }), user: getEnvironmentVariable({ - key: "DB_MONGO_USER", + key: "DB_XTDB_USER", type: z.string(), - fallback: "root", + fallback: "xtdb", }), pass: getEnvironmentVariable({ - key: "DB_MONGO_PASSWORD", + key: "DB_XTDB_PASSWORD", type: z.string(), - fallback: "password", + fallback: "xtdb", }), }, }; diff --git a/platform/database/connection.ts b/platform/database/connection.ts deleted file mode 100644 index 13c523a..0000000 --- a/platform/database/connection.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { MongoClient } from "mongodb"; - -export function getMongoClient(config: MongoConnectionInfo) { - return new MongoClient(getConnectionUrl(config)); -} - -export function getConnectionUrl({ host, port, user, pass }: MongoConnectionInfo): MongoConnectionUrl { - return `mongodb://${user}:${pass}@${host}:${port}`; -} - -/* - |-------------------------------------------------------------------------------- - | Types - |-------------------------------------------------------------------------------- - */ - -export type MongoConnectionUrl = `mongodb://${string}:${string}@${string}:${number}`; - -export type MongoConnectionInfo = { - host: string; - port: number; - user: string; - pass: string; -}; diff --git a/platform/database/id.ts b/platform/database/id.ts deleted file mode 100644 index 1912950..0000000 --- a/platform/database/id.ts +++ /dev/null @@ -1,3 +0,0 @@ -import type { CreateIndexesOptions, IndexSpecification } from "mongodb"; - -export const idIndex: [IndexSpecification, CreateIndexesOptions] = [{ id: 1 }, { unique: true }]; diff --git a/platform/database/package.json b/platform/database/package.json index 5d70f5d..1cf1d1c 100644 --- a/platform/database/package.json +++ b/platform/database/package.json @@ -5,8 +5,7 @@ "type": "module", "dependencies": { "@platform/config": "workspace:*", - "@valkyr/inverse": "npm:@jsr/valkyr__inverse@1.0.1", - "mongodb": "6.20.0", - "zod": "4.1.11" + "postgres": "3.4.7", + "zod": "4.1.12" } } diff --git a/platform/database/parser.ts b/platform/database/parser.ts new file mode 100644 index 0000000..daebf75 --- /dev/null +++ b/platform/database/parser.ts @@ -0,0 +1,29 @@ +import type z from "zod"; +import type { ZodType } from "zod"; + +/** + * Takes a single record from a list of database rows. + * + * @param rows - List of rows to retrieve record from. + */ +export function takeOne( + schema: TSchema, +): (records: unknown[]) => z.output | undefined { + return (records: unknown[]) => { + if (records[0] === undefined) { + return undefined; + } + return schema.parse(records[0]); + }; +} + +/** + * Takes all records from a list of database rows and validates each one. + * + * @param schema - Zod schema to validate each record against. + */ +export function takeAll(schema: TSchema): (records: unknown[]) => z.output[] { + return (records: unknown[]) => { + return records.map((record) => schema.parse(record)); + }; +} diff --git a/platform/database/registrar.ts b/platform/database/registrar.ts deleted file mode 100644 index ecf2c99..0000000 --- a/platform/database/registrar.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { CreateIndexesOptions, Db, IndexSpecification } from "mongodb"; - -import { getCollectionsSet } from "./utilities.ts"; - -/** - * Takes a mongo database and registers the event store collections and - * indexes defined internally. - * - * @param db - Mongo database to register event store collections against. - * @param registrars - List of registrars to register with the database. - * @param logger - Logger method to print internal logs. - */ -export async function register(db: Db, registrars: Registrar[], logger?: (...args: any[]) => any) { - const list = await getCollectionsSet(db); - for (const { name, indexes } of registrars) { - if (list.has(name) === false) { - await db.createCollection(name); - } - for (const [indexSpec, options] of indexes) { - await db.collection(name).createIndex(indexSpec, options); - logger?.("Mongo Event Store > Collection '%s' is indexed [%O] with options %O", name, indexSpec, options ?? {}); - } - logger?.("Mongo Event Store > Collection '%s' is registered", name); - } -} - -export type Registrar = { - name: string; - indexes: [IndexSpecification, CreateIndexesOptions?][]; -}; diff --git a/platform/database/utilities.ts b/platform/database/utilities.ts deleted file mode 100644 index ffd3417..0000000 --- a/platform/database/utilities.ts +++ /dev/null @@ -1,70 +0,0 @@ -import type { Db } from "mongodb"; -import type { ZodObject, ZodType, z } from "zod"; - -/** - * TODO ... - */ -export function takeOne(documents: TDocument[]): TDocument | undefined { - return documents[0]; -} - -/** - * TODO ... - */ -export function makeDocumentParser(schema: TSchema): ModelParserFn { - return ((value: unknown | unknown[]) => { - if (Array.isArray(value)) { - return value.map((value: unknown) => schema.parse(value)); - } - if (value === undefined || value === null) { - return undefined; - } - return schema.parse(value); - }) as ModelParserFn; -} - -/** - * TODO ... - */ -export function toParsedDocuments( - schema: TSchema, -): (documents: unknown[]) => Promise[]> { - return async (documents: unknown[]) => { - const parsed = []; - for (const document of documents) { - parsed.push(await schema.parseAsync(document)); - } - return parsed; - }; -} - -/** - * TODO ... - */ -export function toParsedDocument( - schema: TSchema, -): (document?: unknown) => Promise | undefined> { - return async (document: unknown) => { - if (document === undefined || document === null) { - return undefined; - } - return schema.parseAsync(document); - }; -} - -/** - * Get a Set of collections that exists on a given mongo database instance. - * - * @param db - Mongo database to fetch collection list for. - */ -export async function getCollectionsSet(db: Db) { - return db - .listCollections() - .toArray() - .then((collections) => new Set(collections.map((c) => c.name))); -} - -type ModelParserFn = { - (value: unknown): z.infer | undefined; - (value: unknown[]): z.infer[]; -}; diff --git a/platform/logger/package.json b/platform/logger/package.json index 67ccecd..6d9c1db 100644 --- a/platform/logger/package.json +++ b/platform/logger/package.json @@ -10,6 +10,6 @@ "dependencies": { "@platform/config": "workspace:*", "@valkyr/event-store": "npm:@jsr/valkyr__event-store@2.0.1", - "zod": "4.1.11" + "zod": "4.1.12" } } diff --git a/platform/relay/adapters/http.ts b/platform/relay/adapters/http.ts index 379fa90..125b209 100644 --- a/platform/relay/adapters/http.ts +++ b/platform/relay/adapters/http.ts @@ -1,5 +1,3 @@ -import { encrypt } from "@platform/vault"; - import { assertServerErrorResponse, type RelayAdapter, @@ -69,10 +67,7 @@ export class HttpAdapter implements RelayAdapter { return `${this.url}${endpoint}`; } - async send( - { method, endpoint, query, body, headers = new Headers() }: RelayInput, - publicKey: string, - ): Promise { + async send({ method, endpoint, query, body, headers = new Headers() }: RelayInput): Promise { const init: RequestInit = { method, headers }; // ### Before Request @@ -95,14 +90,6 @@ export class HttpAdapter implements RelayAdapter { } } - // ### Internal - // If public key is present we create a encrypted token on the header that - // is verified by the server before allowing the request through. - - if (publicKey !== undefined) { - headers.set("x-internal", await encrypt("internal", publicKey)); - } - // ### Response return this.request(`${endpoint}${query}`, init); @@ -138,6 +125,9 @@ export class HttpAdapter implements RelayAdapter { * @param body - Request body. */ #getRequestFormat(body: unknown): "form-data" | "json" { + if (body instanceof FormData) { + return "form-data"; + } if (containsFile(body) === true) { return "form-data"; } @@ -245,14 +235,30 @@ export class HttpAdapter implements RelayAdapter { }; } + // ### Error + // If the 'content-type' is not a JSON response from the API then we check if the + // response status is an error code. + + if (response.status >= 400) { + return { + result: "error", + headers: response.headers, + error: { + code: "SERVER_ERROR_RESPONSE", + status: response.status, + message: await response.text(), + }, + }; + } + + // ### Success + // If the 'content-type' is not a JSON response from the API and the request is not + // an error we simply return the pure response in the data key. + return { - result: "error", + result: "success", headers: response.headers, - error: { - code: "UNSUPPORTED_CONTENT_TYPE", - status: response.status, - message: "Unsupported 'content-type' in header returned from server.", - }, + data: response, }; } diff --git a/platform/relay/libraries/client.ts b/platform/relay/libraries/client.ts index 6b035e2..e53007a 100644 --- a/platform/relay/libraries/client.ts +++ b/platform/relay/libraries/client.ts @@ -114,7 +114,7 @@ function getRouteFn(route: Route, { adapter }: Config) { // ### Fetch - const response = await adapter.send(input, route.state.crypto?.publicKey); + const response = await adapter.send(input); if ("data" in response && route.state.response !== undefined) { response.data = route.state.response.parse(response.data); diff --git a/platform/relay/libraries/hooks.ts b/platform/relay/libraries/hooks.ts deleted file mode 100644 index 1356d17..0000000 --- a/platform/relay/libraries/hooks.ts +++ /dev/null @@ -1,10 +0,0 @@ -export type Hooks = { - /** - * Executes when any error is thrown before or during the lifetime - * of the route. This allows for custom handling of errors if the - * route has unique requirements to error handling. - * - * @param error - Error which has been thrown. - */ - onError?: (error: unknown) => Response; -}; diff --git a/platform/relay/libraries/route.ts b/platform/relay/libraries/route.ts index 3b344bc..2ea687e 100644 --- a/platform/relay/libraries/route.ts +++ b/platform/relay/libraries/route.ts @@ -3,7 +3,6 @@ import z, { type ZodObject, type ZodRawShape, type ZodType } from "zod"; import type { ServerContext } from "./context.ts"; import { ServerError, type ServerErrorClass } from "./errors.ts"; -import type { Hooks } from "./hooks.ts"; export class Route { readonly type = "route" as const; @@ -85,23 +84,6 @@ export class Route { return new Route({ ...this.state, meta }); } - /** - * Set cryptographic keys used to resolve cryptographic requests. - * - * @param crypto - Crypto configuration object. - * - * @examples - * - * ```ts - * route.post("/foo").crypto({ publicKey: "..." }); - * ``` - */ - crypto( - crypto: TCrypto, - ): Route & { crypto: TCrypto }>> { - return new Route({ ...this.state, crypto }); - } - /** * Access level of the route which acts as the first barrier of entry * to ensure that requests are valid. @@ -307,19 +289,6 @@ export class Route { ): Route & { handle: THandleFn }> { return new Route({ ...this.state, handle }); } - - /** - * Assign lifetime hooks to a route allowing for custom handling of - * events that can occur during a request or response. - * - * Can be used on both server and client with the appropriate - * implementation. - * - * @param hooks - Hooks to register with the route. - */ - hooks(hooks: THooks): Route & { hooks: THooks }>> { - return new Route({ ...this.state, hooks }); - } } /* @@ -451,9 +420,6 @@ export type RouteFn = (...args: any[]) => any; type RouteState = { method: RouteMethod; path: string; - crypto?: { - publicKey: string; - }; meta?: RouteMeta; access?: RouteAccess; params?: ZodObject; @@ -462,7 +428,6 @@ type RouteState = { errors: ServerErrorClass[]; response?: ZodType; handle?: HandleFn; - hooks?: Hooks; }; export type RouteMeta = { @@ -474,7 +439,7 @@ export type RouteMeta = { export type RouteMethod = "POST" | "GET" | "PUT" | "PATCH" | "DELETE"; -export type RouteAccess = "public" | "session" | ["internal:public", string] | ["internal:session", string]; +export type RouteAccess = "public" | "authenticated"; type HandleFn = any[], TResponse = any> = ( ...args: TArgs diff --git a/platform/relay/mod.ts b/platform/relay/mod.ts index 71a4bbe..a676d10 100644 --- a/platform/relay/mod.ts +++ b/platform/relay/mod.ts @@ -3,6 +3,5 @@ export * from "./libraries/adapter.ts"; export * from "./libraries/client.ts"; export * from "./libraries/context.ts"; export * from "./libraries/errors.ts"; -export * from "./libraries/hooks.ts"; export * from "./libraries/procedure.ts"; export * from "./libraries/route.ts"; diff --git a/platform/relay/package.json b/platform/relay/package.json index a0b7a21..4699bcc 100644 --- a/platform/relay/package.json +++ b/platform/relay/package.json @@ -11,8 +11,7 @@ "@platform/auth": "workspace:*", "@platform/socket": "workspace:*", "@platform/supertokens": "workspace:*", - "@platform/vault": "workspace:*", "path-to-regexp": "8", - "zod": "4.1.11" + "zod": "4.1.12" } } diff --git a/platform/routes/package.json b/platform/routes/package.json index 6f95686..adc419e 100644 --- a/platform/routes/package.json +++ b/platform/routes/package.json @@ -5,6 +5,6 @@ "type": "module", "dependencies": { "@platform/relay": "workspace:*", - "zod": "4.1.11" + "zod": "4.1.12" } } diff --git a/platform/server/api.ts b/platform/server/api.ts index 41234ed..67dd055 100644 --- a/platform/server/api.ts +++ b/platform/server/api.ts @@ -2,7 +2,6 @@ import { logger } from "@platform/logger"; import { BadRequestError, context, - ForbiddenError, InternalServerError, NotFoundError, NotImplementedError, @@ -13,7 +12,6 @@ import { UnauthorizedError, ValidationError, } from "@platform/relay"; -import { decrypt } from "@platform/vault"; const SUPPORTED_MEHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"]; @@ -98,7 +96,7 @@ export class Api { // Execute request and return a response. const response = await this.#getRouteResponse(resolved, request).catch((error) => - this.#getErrorResponse(error, resolved.route, request), + this.#getErrorResponse(error, request), ); return response; @@ -164,31 +162,10 @@ export class Api { ); } - if (route.state.access === "session" && context.isAuthenticated === false) { + if (route.state.access === "authenticated" && context.isAuthenticated === false) { return toResponse(new UnauthorizedError(), request); } - if (Array.isArray(route.state.access)) { - const [access, privateKey] = route.state.access; - const value = request.headers.get("x-internal"); - if (value === null) { - return toResponse( - new ForbiddenError(`Route '${route.method} ${route.path}' is missing 'x-internal' token.`), - request, - ); - } - const decrypted = await decrypt(value, privateKey); - if (decrypted !== "internal") { - return toResponse( - new ForbiddenError(`Route '${route.method} ${route.path}' has invalid 'x-internal' token.`), - request, - ); - } - if (access === "internal:session" && context.isAuthenticated === false) { - return toResponse(new UnauthorizedError(), request); - } - } - // ### Params // If the route has params we want to coerce the values to the expected types. @@ -242,10 +219,7 @@ export class Api { return toResponse(await route.state.handle(...args), request); } - #getErrorResponse(error: unknown, route: Route, request: Request): Response { - if (route?.state.hooks?.onError !== undefined) { - return route.state.hooks.onError(error); - } + #getErrorResponse(error: unknown, request: Request): Response { if (error instanceof ServerError) { return toResponse(error, request); } diff --git a/platform/server/package.json b/platform/server/package.json index c41e985..f7a3345 100644 --- a/platform/server/package.json +++ b/platform/server/package.json @@ -11,6 +11,6 @@ "@platform/socket": "workspace:*", "@platform/storage": "workspace:*", "@valkyr/json-rpc": "npm:@jsr/valkyr__json-rpc@1.1.0", - "zod": "4.1.11" + "zod": "4.1.12" } } diff --git a/platform/spec/package.json b/platform/spec/package.json index 92d8518..680b12a 100644 --- a/platform/spec/package.json +++ b/platform/spec/package.json @@ -6,6 +6,6 @@ "dependencies": { "@platform/models": "workspace:*", "@platform/relay": "workspace:*", - "zod": "4.1.11" + "zod": "4.1.12" } }