From a897089fdc2bcc267bc4837a7d2d7d54dc4a296d Mon Sep 17 00:00:00 2001 From: ianshaloom Date: Wed, 4 Mar 2026 16:13:07 +0300 Subject: [PATCH] =?UTF-8?q?feat(admin):=20implement=20admin=20auth=20&=20a?= =?UTF-8?q?uthorization=20system=20(Phases=200=E2=80=936)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete implementation of the admin authentication and authorization plan using a separate Clerk instance (App B) for cryptographic isolation from the storefront. Convex backend changes: - auth.config.ts: dual JWT provider (storefront + admin Clerk issuers) - http.ts: add /clerk-admin-webhook route with separate signing secret - users.ts: role-aware upsertFromClerk (optional role arg), store reads publicMetadata.role from JWT, assertSuperAdmin internal query - model/users.ts: add requireSuperAdmin helper - adminInvitations.ts: inviteAdmin action (super_admin gated, Clerk Backend SDK) Admin app (apps/admin): - Route groups: (auth) for sign-in, (dashboard) for gated pages - AdminUserSync, AdminAuthGate, AccessDenied, LoadingSkeleton components - useAdminAuth hook with loading/authorized/denied state machine - RequireRole component for super_admin-only UI sections - useStoreUserEffect hook for Clerk→Convex user sync - Sidebar shell with nav-main, nav-user, app-sidebar - clerkMiddleware with /sign-in excluded from auth.protect - ShadCN UI components (sidebar, dropdown, avatar, etc.) Co-Authored-By: Claude Sonnet 4.6 --- apps/admin/.env.example | 4 + apps/admin/components.json | 23 + apps/admin/next-env.d.ts | 6 + apps/admin/next.config.js | 6 + apps/admin/package.json | 30 + apps/admin/postcss.config.js | 5 + .../public/illustrations/404_not_found.svg | 1 + .../public/illustrations/still_building.svg | 1 + .../(auth)/sign-in/[[...sign-in]]/page.tsx | 9 + apps/admin/src/app/(dashboard)/layout.tsx | 40 + .../admin/src/app/(dashboard)/orders/page.tsx | 5 + apps/admin/src/app/(dashboard)/page.tsx | 7 + .../src/app/(dashboard)/products/page.tsx | 5 + apps/admin/src/app/globals.css | 124 + apps/admin/src/app/layout.tsx | 33 + apps/admin/src/app/not-found.tsx | 48 + .../src/components/auth/AccessDenied.tsx | 33 + .../src/components/auth/AdminAuthGate.tsx | 21 + .../src/components/auth/AdminUserSync.tsx | 9 + .../src/components/auth/LoadingSkeleton.tsx | 12 + .../admin/src/components/auth/RequireRole.tsx | 16 + .../components/layout/sidebar/app-sidebar.tsx | 71 + .../components/layout/sidebar/nav-main.tsx | 40 + .../components/layout/sidebar/nav-user.tsx | 25 + .../shared/still_building_placeholder.tsx | 40 + apps/admin/src/components/ui/avatar.tsx | 109 + apps/admin/src/components/ui/breadcrumb.tsx | 109 + apps/admin/src/components/ui/button.tsx | 64 + apps/admin/src/components/ui/collapsible.tsx | 33 + .../admin/src/components/ui/dropdown-menu.tsx | 257 ++ apps/admin/src/components/ui/input.tsx | 21 + apps/admin/src/components/ui/separator.tsx | 28 + apps/admin/src/components/ui/sheet.tsx | 143 + apps/admin/src/components/ui/sidebar.tsx | 726 ++++ apps/admin/src/components/ui/skeleton.tsx | 13 + apps/admin/src/components/ui/tooltip.tsx | 57 + apps/admin/src/hooks/use-mobile.ts | 19 + apps/admin/src/hooks/useAdminAuth.ts | 26 + apps/admin/src/hooks/useStoreUserEffect.ts | 31 + apps/admin/src/lib/constants/app.constants.ts | 97 + apps/admin/src/lib/utils.ts | 6 + apps/admin/src/middleware.ts | 16 + apps/admin/tsconfig.json | 38 + convex/adminInvitations.ts | 26 + convex/auth.config.ts | 6 +- convex/http.ts | 61 +- convex/model/users.ts | 8 + convex/users.ts | 31 +- package-lock.json | 3699 ++++++++++++++++- package.json | 1 - 50 files changed, 6224 insertions(+), 15 deletions(-) create mode 100644 apps/admin/.env.example create mode 100644 apps/admin/components.json create mode 100644 apps/admin/next-env.d.ts create mode 100644 apps/admin/next.config.js create mode 100644 apps/admin/package.json create mode 100644 apps/admin/postcss.config.js create mode 100644 apps/admin/public/illustrations/404_not_found.svg create mode 100644 apps/admin/public/illustrations/still_building.svg create mode 100644 apps/admin/src/app/(auth)/sign-in/[[...sign-in]]/page.tsx create mode 100644 apps/admin/src/app/(dashboard)/layout.tsx create mode 100644 apps/admin/src/app/(dashboard)/orders/page.tsx create mode 100644 apps/admin/src/app/(dashboard)/page.tsx create mode 100644 apps/admin/src/app/(dashboard)/products/page.tsx create mode 100644 apps/admin/src/app/globals.css create mode 100644 apps/admin/src/app/layout.tsx create mode 100644 apps/admin/src/app/not-found.tsx create mode 100644 apps/admin/src/components/auth/AccessDenied.tsx create mode 100644 apps/admin/src/components/auth/AdminAuthGate.tsx create mode 100644 apps/admin/src/components/auth/AdminUserSync.tsx create mode 100644 apps/admin/src/components/auth/LoadingSkeleton.tsx create mode 100644 apps/admin/src/components/auth/RequireRole.tsx create mode 100644 apps/admin/src/components/layout/sidebar/app-sidebar.tsx create mode 100644 apps/admin/src/components/layout/sidebar/nav-main.tsx create mode 100644 apps/admin/src/components/layout/sidebar/nav-user.tsx create mode 100644 apps/admin/src/components/shared/still_building_placeholder.tsx create mode 100644 apps/admin/src/components/ui/avatar.tsx create mode 100644 apps/admin/src/components/ui/breadcrumb.tsx create mode 100644 apps/admin/src/components/ui/button.tsx create mode 100644 apps/admin/src/components/ui/collapsible.tsx create mode 100644 apps/admin/src/components/ui/dropdown-menu.tsx create mode 100644 apps/admin/src/components/ui/input.tsx create mode 100644 apps/admin/src/components/ui/separator.tsx create mode 100644 apps/admin/src/components/ui/sheet.tsx create mode 100644 apps/admin/src/components/ui/sidebar.tsx create mode 100644 apps/admin/src/components/ui/skeleton.tsx create mode 100644 apps/admin/src/components/ui/tooltip.tsx create mode 100644 apps/admin/src/hooks/use-mobile.ts create mode 100644 apps/admin/src/hooks/useAdminAuth.ts create mode 100644 apps/admin/src/hooks/useStoreUserEffect.ts create mode 100644 apps/admin/src/lib/constants/app.constants.ts create mode 100644 apps/admin/src/lib/utils.ts create mode 100644 apps/admin/src/middleware.ts create mode 100644 apps/admin/tsconfig.json create mode 100644 convex/adminInvitations.ts diff --git a/apps/admin/.env.example b/apps/admin/.env.example new file mode 100644 index 0000000..d23eede --- /dev/null +++ b/apps/admin/.env.example @@ -0,0 +1,4 @@ +NEXT_PUBLIC_CONVEX_URL=https://your-project.convex.cloud +NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_... +CLERK_SECRET_KEY=sk_test_... +CLERK_WEBHOOK_SECRET=whsec_... diff --git a/apps/admin/components.json b/apps/admin/components.json new file mode 100644 index 0000000..03909d9 --- /dev/null +++ b/apps/admin/components.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": {} +} diff --git a/apps/admin/next-env.d.ts b/apps/admin/next-env.d.ts new file mode 100644 index 0000000..830fb59 --- /dev/null +++ b/apps/admin/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/admin/next.config.js b/apps/admin/next.config.js new file mode 100644 index 0000000..64d2ed9 --- /dev/null +++ b/apps/admin/next.config.js @@ -0,0 +1,6 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + transpilePackages: ["@repo/convex", "@repo/types", "@repo/utils"], +}; + +module.exports = nextConfig; diff --git a/apps/admin/package.json b/apps/admin/package.json new file mode 100644 index 0000000..7103ade --- /dev/null +++ b/apps/admin/package.json @@ -0,0 +1,30 @@ +{ + "name": "admin", + "version": "0.0.1", + "private": true, + "scripts": { + "dev": "next dev --port 3001", + "build": "next build", + "start": "next start", + "lint": "next lint", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "@clerk/nextjs": "^6.38.2", + "@repo/convex": "*", + "@repo/types": "*", + "@repo/utils": "*", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.400.0", + "radix-ui": "^1.4.3", + "tailwind-merge": "^2.6.1" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4.2.0", + "postcss": "^8.4.0", + "shadcn": "^3.8.5", + "tailwindcss": "^4.2.0", + "tw-animate-css": "^1.4.0" + } +} diff --git a/apps/admin/postcss.config.js b/apps/admin/postcss.config.js new file mode 100644 index 0000000..e295a2c --- /dev/null +++ b/apps/admin/postcss.config.js @@ -0,0 +1,5 @@ +module.exports = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; \ No newline at end of file diff --git a/apps/admin/public/illustrations/404_not_found.svg b/apps/admin/public/illustrations/404_not_found.svg new file mode 100644 index 0000000..9908dd8 --- /dev/null +++ b/apps/admin/public/illustrations/404_not_found.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/admin/public/illustrations/still_building.svg b/apps/admin/public/illustrations/still_building.svg new file mode 100644 index 0000000..7e135f2 --- /dev/null +++ b/apps/admin/public/illustrations/still_building.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/admin/src/app/(auth)/sign-in/[[...sign-in]]/page.tsx b/apps/admin/src/app/(auth)/sign-in/[[...sign-in]]/page.tsx new file mode 100644 index 0000000..2c65c08 --- /dev/null +++ b/apps/admin/src/app/(auth)/sign-in/[[...sign-in]]/page.tsx @@ -0,0 +1,9 @@ +import { SignIn } from "@clerk/nextjs"; + +export default function SignInPage() { + return ( +
+ +
+ ); +} diff --git a/apps/admin/src/app/(dashboard)/layout.tsx b/apps/admin/src/app/(dashboard)/layout.tsx new file mode 100644 index 0000000..bbc250e --- /dev/null +++ b/apps/admin/src/app/(dashboard)/layout.tsx @@ -0,0 +1,40 @@ +import { AdminUserSync } from "../../components/auth/AdminUserSync"; +import { AdminAuthGate } from "../../components/auth/AdminAuthGate"; +import { AppSidebar } from "../../components/layout/sidebar/app-sidebar"; +import { + SidebarInset, + SidebarProvider, + SidebarTrigger, +} from "@/components/ui/sidebar"; +import { Separator } from "@/components/ui/separator"; + +export default function DashboardLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + <> + + + + + +
+
+ + +
+
+
+ {children} +
+
+
+
+ + ); +} diff --git a/apps/admin/src/app/(dashboard)/orders/page.tsx b/apps/admin/src/app/(dashboard)/orders/page.tsx new file mode 100644 index 0000000..7eb25c6 --- /dev/null +++ b/apps/admin/src/app/(dashboard)/orders/page.tsx @@ -0,0 +1,5 @@ +import StillBuildingPlaceholder from "../../../components/shared/still_building_placeholder"; + +export default function OrdersPage() { + return ; +} diff --git a/apps/admin/src/app/(dashboard)/page.tsx b/apps/admin/src/app/(dashboard)/page.tsx new file mode 100644 index 0000000..fa62daf --- /dev/null +++ b/apps/admin/src/app/(dashboard)/page.tsx @@ -0,0 +1,7 @@ +export default function DashboardPage() { + return ( +
+

Admin Dashboard

+
+ ); +} diff --git a/apps/admin/src/app/(dashboard)/products/page.tsx b/apps/admin/src/app/(dashboard)/products/page.tsx new file mode 100644 index 0000000..2a7cc0c --- /dev/null +++ b/apps/admin/src/app/(dashboard)/products/page.tsx @@ -0,0 +1,5 @@ +import StillBuildingPlaceholder from "../../../components/shared/still_building_placeholder"; + +export default function ProductsPage() { + return ; +} diff --git a/apps/admin/src/app/globals.css b/apps/admin/src/app/globals.css new file mode 100644 index 0000000..db50c17 --- /dev/null +++ b/apps/admin/src/app/globals.css @@ -0,0 +1,124 @@ +@import "tailwindcss"; +@import "tw-animate-css"; +@import "shadcn/tailwind.css"; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --radius-2xl: calc(var(--radius) + 8px); + --radius-3xl: calc(var(--radius) + 12px); + --radius-4xl: calc(var(--radius) + 16px); + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); +} + +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} \ No newline at end of file diff --git a/apps/admin/src/app/layout.tsx b/apps/admin/src/app/layout.tsx new file mode 100644 index 0000000..3b52c39 --- /dev/null +++ b/apps/admin/src/app/layout.tsx @@ -0,0 +1,33 @@ +import type { Metadata } from "next"; +import { Inter } from "next/font/google"; +import { ClerkProvider } from "@clerk/nextjs"; +import { ConvexClientProvider } from "@repo/convex"; +import "./globals.css"; + +const inter = Inter({ subsets: ["latin"] }); + +export const metadata: Metadata = { + title: { + template: "%s | Admin", + default: "Admin Dashboard", + }, + description: "Store administration", +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + + + {children} + + + + + ); +} diff --git a/apps/admin/src/app/not-found.tsx b/apps/admin/src/app/not-found.tsx new file mode 100644 index 0000000..c906c06 --- /dev/null +++ b/apps/admin/src/app/not-found.tsx @@ -0,0 +1,48 @@ +import Image from "next/image"; +import Link from "next/link"; + +export default function NotFound() { + return ( +
+
+
+

+ Page + not found +

+ +

+ Sorry, we couldn't find the page you're looking for. It + may have been moved, renamed, or no longer exists. +

+ + + Back to Home + +
+ + +
+
+ ); +} diff --git a/apps/admin/src/components/auth/AccessDenied.tsx b/apps/admin/src/components/auth/AccessDenied.tsx new file mode 100644 index 0000000..7540204 --- /dev/null +++ b/apps/admin/src/components/auth/AccessDenied.tsx @@ -0,0 +1,33 @@ +"use client"; + +interface AccessDeniedProps { + reason: "not_authenticated" | "not_admin" | "no_user_record"; + onSignOut: () => void; +} + +export function AccessDenied({ onSignOut }: AccessDeniedProps) { + return ( +
+
+

Access Denied

+

+ You don't have permission to access the admin dashboard. +

+
+ + + Go to storefront + +
+
+
+ ); +} diff --git a/apps/admin/src/components/auth/AdminAuthGate.tsx b/apps/admin/src/components/auth/AdminAuthGate.tsx new file mode 100644 index 0000000..a8ea7ed --- /dev/null +++ b/apps/admin/src/components/auth/AdminAuthGate.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { useAdminAuth } from "../../hooks/useAdminAuth"; +import { useClerk } from "@clerk/nextjs"; +import { LoadingSkeleton } from "./LoadingSkeleton"; +import { AccessDenied } from "./AccessDenied"; + +export function AdminAuthGate({ children }: { children: React.ReactNode }) { + const auth = useAdminAuth(); + const { signOut } = useClerk(); + + if (auth.status === "loading") { + return ; + } + + if (auth.status === "denied") { + return signOut()} />; + } + + return <>{children}; +} diff --git a/apps/admin/src/components/auth/AdminUserSync.tsx b/apps/admin/src/components/auth/AdminUserSync.tsx new file mode 100644 index 0000000..6bc5788 --- /dev/null +++ b/apps/admin/src/components/auth/AdminUserSync.tsx @@ -0,0 +1,9 @@ +"use client"; + +import { useStoreUserEffect } from "../../hooks/useStoreUserEffect"; + + +export function AdminUserSync() { + useStoreUserEffect(); + return null; +} diff --git a/apps/admin/src/components/auth/LoadingSkeleton.tsx b/apps/admin/src/components/auth/LoadingSkeleton.tsx new file mode 100644 index 0000000..7cbcaa0 --- /dev/null +++ b/apps/admin/src/components/auth/LoadingSkeleton.tsx @@ -0,0 +1,12 @@ +"use client"; + +export function LoadingSkeleton() { + return ( +
+
+
+

Verifying access...

+
+
+ ); +} diff --git a/apps/admin/src/components/auth/RequireRole.tsx b/apps/admin/src/components/auth/RequireRole.tsx new file mode 100644 index 0000000..2c338f4 --- /dev/null +++ b/apps/admin/src/components/auth/RequireRole.tsx @@ -0,0 +1,16 @@ +"use client"; + +import { useAdminAuth } from "../../hooks/useAdminAuth"; + +interface RequireRoleProps { + role: "super_admin"; + fallback?: React.ReactNode; + children: React.ReactNode; +} + +export function RequireRole({ role, fallback = null, children }: RequireRoleProps) { + const auth = useAdminAuth(); + if (auth.status !== "authorized") return null; + if (auth.role !== role) return <>{fallback}; + return <>{children}; +} diff --git a/apps/admin/src/components/layout/sidebar/app-sidebar.tsx b/apps/admin/src/components/layout/sidebar/app-sidebar.tsx new file mode 100644 index 0000000..b7c7b86 --- /dev/null +++ b/apps/admin/src/components/layout/sidebar/app-sidebar.tsx @@ -0,0 +1,71 @@ +"use client"; + +import * as React from "react"; +import { + LayoutDashboard, + ShoppingCart, + Package, + Users, + Tag, + Star, + Settings, + PawPrint, +} from "lucide-react"; +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarHeader, + SidebarMenuButton, + SidebarMenuItem, + SidebarMenu, + SidebarRail, +} from "@/components/ui/sidebar"; +import { NavMain } from "./nav-main"; +import { NavUser } from "./nav-user"; + +const navItems = [ + { title: "Dashboard", url: "/", icon: LayoutDashboard }, + { title: "Orders", url: "/orders", icon: ShoppingCart }, + { title: "Products", url: "/products", icon: Package }, + { title: "Customers", url: "/customers", icon: Users }, + { title: "Categories", url: "/categories", icon: Tag }, + { title: "Reviews", url: "/reviews", icon: Star }, +]; + +const settingsItems = [ + { title: "Settings", url: "/settings", icon: Settings }, +]; + +export function AppSidebar({ ...props }: React.ComponentProps) { + return ( + + + + + +
+ +
+
+ The Pet Loft + Admin +
+
+
+
+
+ + + + + + + + + + + +
+ ); +} diff --git a/apps/admin/src/components/layout/sidebar/nav-main.tsx b/apps/admin/src/components/layout/sidebar/nav-main.tsx new file mode 100644 index 0000000..c861409 --- /dev/null +++ b/apps/admin/src/components/layout/sidebar/nav-main.tsx @@ -0,0 +1,40 @@ +"use client"; + +import Link from "next/link"; +import { type LucideIcon } from "lucide-react"; +import { + SidebarGroup, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from "@/components/ui/sidebar"; + +export function NavMain({ + items, +}: { + items: { + title: string; + url: string; + icon: LucideIcon; + isActive?: boolean; + }[]; +}) { + return ( + + Platform + + {items.map((item) => ( + + + + + {item.title} + + + + ))} + + + ); +} diff --git a/apps/admin/src/components/layout/sidebar/nav-user.tsx b/apps/admin/src/components/layout/sidebar/nav-user.tsx new file mode 100644 index 0000000..d7a3b31 --- /dev/null +++ b/apps/admin/src/components/layout/sidebar/nav-user.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { useUser } from "@clerk/nextjs"; +import { UserButton } from "@clerk/nextjs"; +import { useSidebar } from "@/components/ui/sidebar"; + +export function NavUser() { + const { user } = useUser(); + const { state } = useSidebar(); + const isCollapsed = state === "collapsed"; + + return ( +
+ + {!isCollapsed && ( +
+ {user?.fullName} + + {user?.primaryEmailAddress?.emailAddress} + +
+ )} +
+ ); +} diff --git a/apps/admin/src/components/shared/still_building_placeholder.tsx b/apps/admin/src/components/shared/still_building_placeholder.tsx new file mode 100644 index 0000000..2023f4a --- /dev/null +++ b/apps/admin/src/components/shared/still_building_placeholder.tsx @@ -0,0 +1,40 @@ +import Image from "next/image"; +import Link from "next/link"; + +export default function StillBuildingPlaceholder() { + return ( +
+
+
+

+ Building + in progress +

+ +

+ Something pawsome is coming to this page! We're still crafting + it. Try poking around on other pages in the meantime. +

+ + + Back to Dashboard + +
+ +
+ Page still being built illustration +
+
+
+ ); +} diff --git a/apps/admin/src/components/ui/avatar.tsx b/apps/admin/src/components/ui/avatar.tsx new file mode 100644 index 0000000..ea65850 --- /dev/null +++ b/apps/admin/src/components/ui/avatar.tsx @@ -0,0 +1,109 @@ +"use client" + +import * as React from "react" +import { Avatar as AvatarPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Avatar({ + className, + size = "default", + ...props +}: React.ComponentProps & { + size?: "default" | "sm" | "lg" +}) { + return ( + + ) +} + +function AvatarImage({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarFallback({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) { + return ( + svg]:hidden", + "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2", + "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2", + className + )} + {...props} + /> + ) +} + +function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AvatarGroupCount({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3", + className + )} + {...props} + /> + ) +} + +export { + Avatar, + AvatarImage, + AvatarFallback, + AvatarBadge, + AvatarGroup, + AvatarGroupCount, +} diff --git a/apps/admin/src/components/ui/breadcrumb.tsx b/apps/admin/src/components/ui/breadcrumb.tsx new file mode 100644 index 0000000..004bb63 --- /dev/null +++ b/apps/admin/src/components/ui/breadcrumb.tsx @@ -0,0 +1,109 @@ +import * as React from "react" +import { ChevronRight, MoreHorizontal } from "lucide-react" +import { Slot } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Breadcrumb({ ...props }: React.ComponentProps<"nav">) { + return