diff --git a/packages/stack-server/package.json b/packages/stack-server/package.json index 59e48c1ea..b15e7dc22 100644 --- a/packages/stack-server/package.json +++ b/packages/stack-server/package.json @@ -84,6 +84,7 @@ "date-fns": "^3.6.0", "dotenv-cli": "^7.3.0", "geist": "^1", + "input-otp": "^1.2.4", "jose": "^5.2.2", "lucide-react": "^0.378.0", "next": "^14.1", @@ -96,6 +97,7 @@ "react-email": "2.1.0", "react-hook-form": "^7.51.4", "react-icons": "^5.0.1", + "react-resizable-panels": "^2.0.19", "rehype-katex": "^7", "remark-gfm": "^4", "remark-heading-id": "^1.0.1", diff --git a/packages/stack-server/src/app/(main)/(protected)/(outside-dashbaord)/new-project/page-client.tsx b/packages/stack-server/src/app/(main)/(protected)/(outside-dashbaord)/new-project/page-client.tsx index c08d684f4..4991b071f 100644 --- a/packages/stack-server/src/app/(main)/(protected)/(outside-dashbaord)/new-project/page-client.tsx +++ b/packages/stack-server/src/app/(main)/(protected)/(outside-dashbaord)/new-project/page-client.tsx @@ -7,7 +7,7 @@ import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { Form } from "@/components/ui/form"; import { InputField, ListSwitchField } from "@/components/form-fields"; -import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises"; +import { runAsynchronously, wait } from "@stackframe/stack-shared/dist/utils/promises"; import { useRouter } from "next/navigation"; import { useState } from "react"; import { AsyncButton } from "@/components/ui/button"; @@ -55,6 +55,7 @@ export default function PageClient () { ...projectUpdate, }); await router.push('/projects/' + newProject.id); + await wait(2000); } finally { setLoading(false); } @@ -70,7 +71,7 @@ export default function PageClient () {
runAsynchronously(form.handleSubmit(onSubmit)(e))} className="space-y-6"> - + {mockProject ? ( -
+ // The inert attribute is not available in typescript, so this is a hack to make type works +
{/* a transparent cover that prevents the card being clicked */}
diff --git a/packages/stack-server/src/app/(main)/(protected)/(outside-dashbaord)/projects/page-client.tsx b/packages/stack-server/src/app/(main)/(protected)/(outside-dashbaord)/projects/page-client.tsx index 5693c26fa..5b145b4a1 100644 --- a/packages/stack-server/src/app/(main)/(protected)/(outside-dashbaord)/projects/page-client.tsx +++ b/packages/stack-server/src/app/(main)/(protected)/(outside-dashbaord)/projects/page-client.tsx @@ -2,9 +2,10 @@ import { ProjectCard } from "@/components/project-card"; import { SearchBar } from "@/components/search-bar"; -import { Button } from "@/components/ui/button"; +import { AsyncButton } from "@/components/ui/button"; import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { useUser } from "@stackframe/stack"; +import { wait } from "@stackframe/stack-shared/dist/utils/promises"; import { useRouter } from "next/navigation"; import { useMemo, useState } from "react"; @@ -35,7 +36,11 @@ export default function PageClient() { return ( <>
- setSearch(e.target.value)} /> + setSearch(e.target.value)} + />
- + { + await router.push('/new-project'); + return await wait(2000); + }} + >Create Project +
diff --git a/packages/stack-server/src/app/(main)/(protected)/projects/[projectId]/layout.tsx b/packages/stack-server/src/app/(main)/(protected)/projects/[projectId]/layout.tsx index 31a65e87c..b1ad8b73d 100644 --- a/packages/stack-server/src/app/(main)/(protected)/projects/[projectId]/layout.tsx +++ b/packages/stack-server/src/app/(main)/(protected)/projects/[projectId]/layout.tsx @@ -6,7 +6,7 @@ export default function Layout(props: { children: React.ReactNode, params: { pro return ( - + {props.children} diff --git a/packages/stack-server/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout-old.tsx b/packages/stack-server/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout-old.tsx new file mode 100644 index 000000000..3460d72dd --- /dev/null +++ b/packages/stack-server/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout-old.tsx @@ -0,0 +1,62 @@ +"use client";; +import { Drawer, Stack, useTheme } from "@mui/joy"; +import { useState } from "react"; +import { Sidebar } from "./sidebar"; +import { Header } from "./header"; +import { navigationItems } from "./navigation-data"; + +export default function SidebarLayout(props: { children: React.ReactNode, params: { projectId: string } }) { + const theme = useTheme(); + const [isSidebarOpen, setIsSidebarOpen] = useState(false); + + const isCompactMediaQuery = theme.breakpoints.down("md"); + + const headerHeight = 50; + + return ( + <> + + + +
setIsSidebarOpen(true)} + /> + + + {props.children} + + + + + setIsSidebarOpen(false)} + > + + + + ); +} diff --git a/packages/stack-server/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx b/packages/stack-server/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx index f59da5379..689f76bb6 100644 --- a/packages/stack-server/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx +++ b/packages/stack-server/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx @@ -1,64 +1,328 @@ -"use client"; +'use client';; +import Link from "next/link"; +import { + Book, + ChevronDownIcon, + Globe, + KeyRound, + LockKeyhole, + LucideIcon, + Menu, + Settings2, + ShieldEllipsis, + User, + Users, +} from "lucide-react"; +import { Link as LinkIcon } from "lucide-react"; -import { Drawer, Stack, useTheme } from "@mui/joy"; -import { useState } from "react"; -import { Sidebar } from "./sidebar"; -import { Header } from "./header"; -import { Icon } from '@/components/icon'; -import { navigationItems } from "./navigation-data"; +import { cn } from "@/lib/utils"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; +import { Project, UserButton, useUser } from "@stackframe/stack"; +import { useColorScheme } from "@mui/joy"; +import { usePathname } from "next/navigation"; +import { useMemo, useState } from "react"; +import { + Breadcrumb, + BreadcrumbEllipsis, + BreadcrumbItem, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from "@/components/ui/breadcrumb"; +import { ProjectSwitcher } from "@/components/project-switcher"; +import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"; +import { DropdownMenu } from "@radix-ui/react-dropdown-menu"; +import { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; -export default function SidebarLayout(props: { children: React.ReactNode, params: { projectId: string } }) { - const theme = useTheme(); - const [isSidebarOpen, setIsSidebarOpen] = useState(false); +type Label = { + name: string, + type: 'label', +}; - const isCompactMediaQuery = theme.breakpoints.down("md"); +type Item = { + name: string, + href: string, + icon: LucideIcon, + regex: RegExp, + type: 'item', +}; - const headerHeight = 50; +type Hidden = { + name: string | string[], + regex: RegExp, + type: 'hidden', +}; +const navigationItems: (Label | Item | Hidden)[] = [ + { + name: "Users", + type: 'label' + }, + { + name: "Users", + href: "/users", + regex: /^\/projects\/[^\/]+\/users$/, + icon: User, + type: 'item' + }, + { + name: "Auth Methods", + href: "/providers", + regex: /^\/projects\/[^\/]+\/providers$/, + icon: ShieldEllipsis, + type: 'item' + }, + { + name: "Teams", + type: 'label' + }, + { + name: "Teams", + href: "/teams", + regex: /^\/projects\/[^\/]+\/teams$/, + icon: Users, + type: 'item' + }, + { + name: ["Team", "Members"], + regex: /^\/projects\/[^\/]+\/teams\/[^\/]+$/, + type: "hidden", + }, + { + name: "Permissions", + href: "/team-permissions", + regex: /^\/projects\/[^\/]+\/team-permissions$/, + icon: LockKeyhole, + type: 'item' + }, + { + name: "Settings", + type: 'label' + }, + { + name: "Domains & Handlers", + href: "/urls-and-callbacks", + regex: /^\/projects\/[^\/]+\/urls-and-callbacks$/, + icon: LinkIcon, + type: 'item' + }, + { + name: "Team Settings", + href: "/team-settings", + regex: /^\/projects\/[^\/]+\/team-settings$/, + icon: Settings2, + type: 'item' + }, + { + name: "Environment", + href: "/environment", + regex: /^\/projects\/[^\/]+\/environment$/, + icon: Globe, + type: 'item' + }, + { + name: "API Keys", + href: "/api-keys", + regex: /^\/projects\/[^\/]+\/api-keys$/, + icon: KeyRound, + type: 'item' + }, +]; + +export function NavItem({ item, projectId, onClick }: { item: Item, projectId: string, onClick?: () => void}) { + const pathname = usePathname(); + const selected = useMemo(() => { + return item.regex.test(pathname); + }, [item.regex, pathname]); + return ( - <> - - - -
setIsSidebarOpen(true)} - /> - - - {props.children} - - - - - setIsSidebarOpen(false)} - > - - - + + + {item.name} + ); } + +export function SidebarContent({ projectId, onNavigate }: { projectId: string, onNavigate?: () => void }) { + const { mode, setMode } = useColorScheme(); + + return ( +
+
+ +
+
+ {navigationItems.map((item, index) => { + if (item.type === 'label') { + return
+ {item.name} +
; + } else if (item.type === 'item') { + return
+ +
; + } + })} +
+ +
+ +
+ +
+ + {/*
*/} +
+ setMode(mode === 'light' ? 'dark' : 'light')} /> +
+
+ ); +} + +export function HeaderBreadcrumb({ + mobile, + projectId +}: { + projectId: string, + mobile?: boolean, +}) { + const pathname = usePathname(); + const user = useUser({ or: 'redirect', projectIdMustMatch: "internal" }); + const projects = user.useOwnedProjects(); + + const selectedItemNames: string[] = useMemo(() => { + const name = navigationItems.find((item) => { + if (item.type === 'label') { + return false; + } else { + return item.regex.test(pathname); + } + })?.name; + + if (!name) { + return []; + } else if (name instanceof Array) { + return name; + } else { + return [name]; + } + }, [pathname]); + + const selectedProject: Project | undefined = useMemo(() => { + return projects.find((project) => project.id === projectId); + }, [projectId, projects]); + + if (mobile) { + return ( + + + + Home + + + + + + + + + + + {selectedProject?.displayName} + + + {selectedItemNames.map((name, index) => ( + + {name} + + ))} + + + + + + + ); + } else { + return ( + + + + Home + + + + {selectedProject?.displayName} + + + {selectedItemNames.map((name, index) => ( + + {name} + + ))} + + + ); + } +} + +export default function SidebarLayout(props: { projectId: string, children?: React.ReactNode }) { + const [sidebarOpen, setSidebarOpen] = useState(false); + return ( +
+
+ +
+
+
+
+ +
+ +
+ setSidebarOpen(open)} open={sidebarOpen}> + + + + + setSidebarOpen(false)} /> + + + +
+ +
+
+ + +
+
+ {props.children} +
+
+
+ ); +} \ No newline at end of file diff --git a/packages/stack-server/src/app/layout.tsx b/packages/stack-server/src/app/layout.tsx index b12d8c160..3ab71704e 100644 --- a/packages/stack-server/src/app/layout.tsx +++ b/packages/stack-server/src/app/layout.tsx @@ -1,17 +1,15 @@ import '../polyfills'; - +import './globals.css'; +import React from 'react'; import type { Metadata } from 'next'; import {GeistSans} from 'geist/font/sans'; import {GeistMono} from "geist/font/mono"; import { SnackbarProvider } from '@/hooks/use-snackbar'; import { Analytics } from "@vercel/analytics/react"; import { Inter as FontSans } from "next/font/google"; - -import './globals.css'; import ThemeProvider from '@/theme'; import { StyleLink } from '@/components/style-link'; import { getEnvVariable } from '@stackframe/stack-shared/dist/utils/env'; -import React from 'react'; import { stackServerApp } from '@/stack'; import { StackProvider, StackTheme } from '@stackframe/stack'; import { cn } from '@/lib/utils'; @@ -68,7 +66,7 @@ const theme = { backgroundColor: 'white', neutralColor: '#e4e4e7', }, - } + }, }; export default function RootLayout({ diff --git a/packages/stack-server/src/components/form-fields.tsx b/packages/stack-server/src/components/form-fields.tsx index 9ba199774..ba63202e3 100644 --- a/packages/stack-server/src/components/form-fields.tsx +++ b/packages/stack-server/src/components/form-fields.tsx @@ -4,11 +4,18 @@ import { FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/comp import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; + +export function Label({ required, children }: { children?: string, required?: boolean }) { + return {children} {required ? {' *'} : null}; +} + + export function InputField(props: { control: Control, name: Path, label: string, placeholder?: string, + required?: boolean, }) { return ( (props: { name={props.name} render={({ field }) => ( - {props.label} + @@ -30,7 +37,8 @@ export function InputField(props: { export function SwitchField(props: { control: Control, name: Path, - label: string, + label: string, + required?: boolean, }) { return ( (props: {
- {props.label} +
(props: { name: Path, label: string, options: { value: string, label: string }[], + required?: boolean, }) { return ( (props: { {props.options.map(provider => (
- {provider.label} +
+

+ {props.displayName?.slice(0,1)?.toUpperCase()} +

+
+ ); } -export function ProjectSwitcher({ - isCollapsed, - accounts, -}: ProjectSwitcherProps) { - const [selectedAccount, setSelectedAccount] = React.useState( - accounts[0].email - ); +export function ProjectSwitcher(props: { currentProjectId: string }) { + const router = useRouter(); + const user = useUser({ or: 'redirect', projectIdMustMatch: "internal" }); + const rawProjects = user.useOwnedProjects(); + const { currentProject, projects } = useMemo(() => { + const currentProject = rawProjects.find((project) => project.id === props.currentProjectId); + const projects = rawProjects.sort((a, b) => b.id === props.currentProjectId ? 1 : -1); + return { currentProject, projects }; + }, [props.currentProjectId, rawProjects]); return ( - { router.push(`/projects/${value}`); }}> span]:line-clamp-1 [&>span]:flex [&>span]:w-full [&>span]:items-center [&>span]:gap-1 [&>span]:truncate [&_svg]:h-4 [&_svg]:w-4 [&_svg]:shrink-0", - isCollapsed && - "flex h-9 w-9 shrink-0 items-center justify-center p-0 [&>span]:w-auto [&>svg]:hidden" - )} + className="h-10 flex items-center gap-2 [&>span]:line-clamp-1 [&>span]:flex [&>span]:w-full [&>span]:items-center [&>span]:gap-1 [&>span]:truncate [&_svg]:h-4 [&_svg]:w-4 [&_svg]:shrink-0" aria-label="Select account" > - {accounts.find((account) => account.email === selectedAccount)?.icon} - - { accounts.find((account) => account.email === selectedAccount)?.label } + + + { currentProject?.displayName } - {accounts.map((account) => ( - -
- {account.icon} - {account.email} + {projects.map((p) => ( + +
+ + {p.displayName}
))} diff --git a/packages/stack-server/src/components/ui/breadcrumb.tsx b/packages/stack-server/src/components/ui/breadcrumb.tsx new file mode 100644 index 000000000..e35bdd4f3 --- /dev/null +++ b/packages/stack-server/src/components/ui/breadcrumb.tsx @@ -0,0 +1,115 @@ +import * as React from "react"; +import { ChevronRightIcon, DotsHorizontalIcon } from "@radix-ui/react-icons"; +import { Slot } from "@radix-ui/react-slot"; + +import { cn } from "@/lib/utils"; + +const Breadcrumb = React.forwardRef< + HTMLElement, + React.ComponentPropsWithoutRef<"nav"> & { + separator?: React.ReactNode, + } +>(({ ...props }, ref) =>