diff --git a/.github/workflows/deploy-preview.yml b/.github/workflows/deploy-preview.yml index 76b3783ed56..a3252258e04 100644 --- a/.github/workflows/deploy-preview.yml +++ b/.github/workflows/deploy-preview.yml @@ -118,9 +118,11 @@ jobs: VERCEL_PROJECT_ID: ${{ secrets.VERCEL_API_PROJECT_ID }} DATABASE_URL: ${{ env.DATABASE_URL }} DATABASE_URL_UNPOOLED: ${{ env.DATABASE_URL_UNPOOLED }} + BLOB_READ_WRITE_TOKEN: ${{ secrets.BLOB_READ_WRITE_TOKEN }} NEXT_PUBLIC_WEB_URL: https://${{ env.WEB_ALIAS }} NEXT_PUBLIC_ADMIN_URL: https://${{ env.ADMIN_ALIAS }} - MOCK_USER_ID: ${{ secrets.MOCK_USER_ID }} + CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} + CLERK_WEBHOOK_SECRET: ${{ secrets.CLERK_WEBHOOK_SECRET }} run: | vercel pull --yes --environment=preview --token=$VERCEL_TOKEN vercel build --token=$VERCEL_TOKEN @@ -191,7 +193,9 @@ jobs: NEXT_PUBLIC_API_URL: https://${{ env.API_ALIAS }} NEXT_PUBLIC_MARKETING_URL: https://${{ env.MARKETING_ALIAS }} NEXT_PUBLIC_DOCS_URL: https://${{ env.DOCS_ALIAS }} - MOCK_USER_ID: ${{ secrets.MOCK_USER_ID }} + CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} + NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }} + NEXT_PUBLIC_COOKIE_DOMAIN: ${{ secrets.NEXT_PUBLIC_COOKIE_DOMAIN }} run: | vercel pull --yes --environment=preview --token=$VERCEL_TOKEN vercel build --token=$VERCEL_TOKEN @@ -246,6 +250,10 @@ jobs: VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} VERCEL_PROJECT_ID: ${{ secrets.VERCEL_MARKETING_PROJECT_ID }} NEXT_PUBLIC_API_URL: https://${{ env.API_ALIAS }} + NEXT_PUBLIC_WEB_URL: https://${{ env.WEB_ALIAS }} + CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} + NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }} + NEXT_PUBLIC_COOKIE_DOMAIN: ${{ secrets.NEXT_PUBLIC_COOKIE_DOMAIN }} run: | vercel pull --yes --environment=preview --token=$VERCEL_TOKEN vercel build --token=$VERCEL_TOKEN @@ -314,7 +322,9 @@ jobs: DATABASE_URL_UNPOOLED: ${{ env.DATABASE_URL_UNPOOLED }} NEXT_PUBLIC_API_URL: https://${{ env.API_ALIAS }} NEXT_PUBLIC_WEB_URL: https://${{ env.WEB_ALIAS }} - MOCK_USER_ID: ${{ secrets.MOCK_USER_ID }} + CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} + NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }} + NEXT_PUBLIC_COOKIE_DOMAIN: ${{ secrets.NEXT_PUBLIC_COOKIE_DOMAIN }} run: | vercel pull --yes --environment=preview --token=$VERCEL_TOKEN vercel build --token=$VERCEL_TOKEN diff --git a/.github/workflows/deploy-production.yml b/.github/workflows/deploy-production.yml index c21b7970362..0ca94697c21 100644 --- a/.github/workflows/deploy-production.yml +++ b/.github/workflows/deploy-production.yml @@ -72,7 +72,8 @@ jobs: DATABASE_URL_UNPOOLED: ${{ secrets.DATABASE_URL_UNPOOLED }} NEXT_PUBLIC_WEB_URL: ${{ secrets.NEXT_PUBLIC_WEB_URL }} NEXT_PUBLIC_ADMIN_URL: ${{ secrets.NEXT_PUBLIC_ADMIN_URL }} - MOCK_USER_ID: ${{ secrets.MOCK_USER_ID }} + CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} + CLERK_WEBHOOK_SECRET: ${{ secrets.CLERK_WEBHOOK_SECRET }} run: | vercel pull --yes --environment=production --token=$VERCEL_TOKEN vercel build --prod --token=$VERCEL_TOKEN @@ -115,7 +116,9 @@ jobs: NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }} NEXT_PUBLIC_MARKETING_URL: ${{ secrets.NEXT_PUBLIC_MARKETING_URL }} NEXT_PUBLIC_DOCS_URL: ${{ secrets.NEXT_PUBLIC_DOCS_URL }} - MOCK_USER_ID: ${{ secrets.MOCK_USER_ID }} + CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} + NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }} + NEXT_PUBLIC_COOKIE_DOMAIN: ${{ secrets.NEXT_PUBLIC_COOKIE_DOMAIN }} run: | vercel pull --yes --environment=production --token=$VERCEL_TOKEN vercel build --prod --token=$VERCEL_TOKEN @@ -154,6 +157,9 @@ jobs: VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} VERCEL_PROJECT_ID: ${{ secrets.VERCEL_MARKETING_PROJECT_ID }} NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }} + CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} + NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }} + NEXT_PUBLIC_COOKIE_DOMAIN: ${{ secrets.NEXT_PUBLIC_COOKIE_DOMAIN }} run: | vercel pull --yes --environment=production --token=$VERCEL_TOKEN vercel build --prod --token=$VERCEL_TOKEN @@ -195,7 +201,9 @@ jobs: DATABASE_URL_UNPOOLED: ${{ secrets.DATABASE_URL_UNPOOLED }} NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }} NEXT_PUBLIC_WEB_URL: ${{ secrets.NEXT_PUBLIC_WEB_URL }} - MOCK_USER_ID: ${{ secrets.MOCK_USER_ID }} + CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} + NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }} + NEXT_PUBLIC_COOKIE_DOMAIN: ${{ secrets.NEXT_PUBLIC_COOKIE_DOMAIN }} run: | vercel pull --yes --environment=production --token=$VERCEL_TOKEN vercel build --prod --token=$VERCEL_TOKEN diff --git a/apps/admin/package.json b/apps/admin/package.json index 850a5e4c1d7..e7b9daf9551 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -11,6 +11,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@clerk/nextjs": "^6.36.2", "@superset/db": "workspace:*", "@superset/queries": "workspace:*", "@superset/shared": "workspace:*", @@ -22,12 +23,12 @@ "@trpc/client": "^11.7.1", "@trpc/server": "^11.7.1", "@trpc/tanstack-react-query": "^11.7.1", - "geist": "^1.5.1", - "lucide-react": "^0.560.0", + "date-fns": "^4.1.0", "next": "^16.0.10", "next-themes": "^0.4.6", "react": "^19.2.3", "react-dom": "^19.2.3", + "react-icons": "^5.5.0", "server-only": "^0.0.1", "superjson": "^2.2.5", "zod": "^4.1.13" diff --git a/apps/admin/src/app/(dashboard)/components/AppSidebar/AppSidebar.tsx b/apps/admin/src/app/(dashboard)/components/AppSidebar/AppSidebar.tsx index 4c25a046f22..e5ebb547cd8 100644 --- a/apps/admin/src/app/(dashboard)/components/AppSidebar/AppSidebar.tsx +++ b/apps/admin/src/app/(dashboard)/components/AppSidebar/AppSidebar.tsx @@ -1,5 +1,6 @@ "use client"; +import type { RouterOutputs } from "@superset/trpc"; import { Collapsible, CollapsibleContent, @@ -18,19 +19,8 @@ import { SidebarMenuItem, SidebarRail, } from "@superset/ui/sidebar"; -import { - BarChart3, - Bot, - ChevronRight, - Database, - Home, - Settings, - Shield, - Users, - Webhook, -} from "lucide-react"; +import { LuChevronRight, LuHouse, LuUsers } from "react-icons/lu"; -import type { User } from "@/lib/auth/types"; import { AppSidebarHeader } from "./components/AppSidebarHeader"; import { NavUser } from "./components/NavUser"; import { SearchForm } from "./components/SearchForm"; @@ -42,7 +32,7 @@ const navigation = [ { title: "Dashboard", url: "/", - icon: Home, + icon: LuHouse, }, ], }, @@ -52,75 +42,18 @@ const navigation = [ { title: "All Users", url: "/users", - icon: Users, + icon: LuUsers, }, { title: "Deleted Users", url: "/users/deleted", }, - { - title: "Permissions", - url: "/users/permissions", - icon: Shield, - }, - ], - }, - { - title: "Analytics", - items: [ - { - title: "Overview", - url: "/analytics", - icon: BarChart3, - }, - { - title: "User Activity", - url: "/analytics/activity", - }, - { - title: "Performance", - url: "/analytics/performance", - }, - ], - }, - { - title: "AI Lab", - items: [ - { - title: "Plan Testing", - url: "/ai-lab", - icon: Bot, - }, - { - title: "Model Config", - url: "/ai-lab/models", - }, - ], - }, - { - title: "System", - items: [ - { - title: "Database", - url: "/system/database", - icon: Database, - }, - { - title: "Webhooks", - url: "/system/webhooks", - icon: Webhook, - }, - { - title: "Settings", - url: "/settings", - icon: Settings, - }, ], }, ]; export interface AppSidebarProps extends React.ComponentProps { - user: User; + user: NonNullable; } export function AppSidebar({ user, ...props }: AppSidebarProps) { @@ -145,7 +78,7 @@ export function AppSidebar({ user, ...props }: AppSidebarProps) { > {section.title} - + diff --git a/apps/admin/src/app/(dashboard)/components/AppSidebar/components/NavUser/NavUser.tsx b/apps/admin/src/app/(dashboard)/components/AppSidebar/components/NavUser/NavUser.tsx index 79210f75b33..774cc5e0989 100644 --- a/apps/admin/src/app/(dashboard)/components/AppSidebar/components/NavUser/NavUser.tsx +++ b/apps/admin/src/app/(dashboard)/components/AppSidebar/components/NavUser/NavUser.tsx @@ -1,5 +1,7 @@ "use client"; +import { useClerk } from "@clerk/nextjs"; +import type { RouterOutputs } from "@superset/trpc"; import { Avatar, AvatarFallback, AvatarImage } from "@superset/ui/avatar"; import { DropdownMenu, @@ -17,22 +19,22 @@ import { useSidebar, } from "@superset/ui/sidebar"; import { - BadgeCheck, - Bell, - ChevronsUpDown, - LogOut, - Settings, -} from "lucide-react"; -import { useSignOut } from "@/lib/auth/client"; -import type { User } from "@/lib/auth/types"; + LuBadgeCheck, + LuBell, + LuChevronsUpDown, + LuLogOut, + LuSettings, +} from "react-icons/lu"; + +import { env } from "@/env"; export interface NavUserProps { - user: User; + user: NonNullable; } export function NavUser({ user }: NavUserProps) { const { isMobile } = useSidebar(); - const { signOut } = useSignOut(); + const { signOut } = useClerk(); const userInitials = user.name .split(" ") @@ -49,7 +51,10 @@ export function NavUser({ user }: NavUserProps) { className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground" > - + {userInitials} @@ -58,7 +63,7 @@ export function NavUser({ user }: NavUserProps) { {user.name} {user.email} - +
- + {userInitials} @@ -84,21 +92,23 @@ export function NavUser({ user }: NavUserProps) { - + Account - + Settings - + Notifications - - + signOut({ redirectUrl: env.NEXT_PUBLIC_WEB_URL })} + > + Log out diff --git a/apps/admin/src/app/(dashboard)/components/AppSidebar/components/SearchForm/SearchForm.tsx b/apps/admin/src/app/(dashboard)/components/AppSidebar/components/SearchForm/SearchForm.tsx index e8d9b036a46..1607fec1aa5 100644 --- a/apps/admin/src/app/(dashboard)/components/AppSidebar/components/SearchForm/SearchForm.tsx +++ b/apps/admin/src/app/(dashboard)/components/AppSidebar/components/SearchForm/SearchForm.tsx @@ -6,7 +6,7 @@ import { SidebarGroupContent, SidebarInput, } from "@superset/ui/sidebar"; -import { Search } from "lucide-react"; +import { LuSearch } from "react-icons/lu"; export function SearchForm({ ...props }: React.ComponentProps<"form">) { return ( @@ -17,7 +17,7 @@ export function SearchForm({ ...props }: React.ComponentProps<"form">) { Search - + diff --git a/apps/admin/src/app/(dashboard)/layout.tsx b/apps/admin/src/app/(dashboard)/layout.tsx index 56f8522c366..652b1eec48b 100644 --- a/apps/admin/src/app/(dashboard)/layout.tsx +++ b/apps/admin/src/app/(dashboard)/layout.tsx @@ -12,10 +12,8 @@ import { SidebarProvider, SidebarTrigger, } from "@superset/ui/sidebar"; -import { redirect } from "next/navigation"; -import { env } from "@/env"; -import { currentUser } from "@/lib/auth/server"; +import { api } from "@/trpc/server"; import { AppSidebar } from "./components/AppSidebar"; @@ -24,11 +22,11 @@ export default async function DashboardLayout({ }: { children: React.ReactNode; }) { - const user = await currentUser(); + const trpc = await api(); + const user = await trpc.user.me.query(); - // Redirect unauthorized users to web app if (!user) { - redirect(env.NEXT_PUBLIC_WEB_URL); + throw new Error("User not found"); } return ( diff --git a/apps/admin/src/app/(dashboard)/users/components/UsersTable/UsersTable.tsx b/apps/admin/src/app/(dashboard)/users/components/UsersTable/UsersTable.tsx new file mode 100644 index 00000000000..3ccac15f606 --- /dev/null +++ b/apps/admin/src/app/(dashboard)/users/components/UsersTable/UsersTable.tsx @@ -0,0 +1,245 @@ +"use client"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@superset/ui/alert-dialog"; +import { Avatar, AvatarFallback, AvatarImage } from "@superset/ui/avatar"; +import { Button } from "@superset/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@superset/ui/card"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@superset/ui/dropdown-menu"; +import { toast } from "@superset/ui/sonner"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@superset/ui/table"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { formatDistanceToNow } from "date-fns"; +import { useState } from "react"; +import { LuEllipsis, LuLoaderCircle, LuTrash2, LuUser } from "react-icons/lu"; + +import { useTRPC } from "@/trpc/react"; + +export function UsersTable() { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const { data, isLoading, error } = useQuery( + trpc.admin.listActiveUsers.queryOptions(), + ); + + const [userToDelete, setUserToDelete] = useState<{ + id: string; + email: string; + name: string; + } | null>(null); + + const deleteMutation = useMutation( + trpc.admin.permanentlyDeleteUser.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: trpc.admin.listActiveUsers.queryKey(), + }); + toast.success(`${userToDelete?.name} has been permanently deleted`); + setUserToDelete(null); + }, + onError: (error) => { + toast.error(`Failed to delete user: ${error.message}`); + }, + }), + ); + + const handleDelete = () => { + if (!userToDelete) return; + deleteMutation.mutate({ userId: userToDelete.id }); + }; + + if (isLoading) { + return ( + + + + + + ); + } + + if (error) { + return ( + + +
+ +
+

Failed to load users

+

+ {error.message || "An error occurred while fetching users"} +

+
+
+ ); + } + + if (!data || data.length === 0) { + return ( + + + +

No active users

+

+ Users will appear here as they sign up +

+
+
+ ); + } + + return ( + <> + + + Active Users + + {data.length} active user{data.length !== 1 ? "s" : ""} + + + + + + + User + Email + Joined + + + + + {data.map((user) => ( + + +
+ + + + {user.name + .split(" ") + .map((n) => n[0]) + .join("") + .toUpperCase() + .slice(0, 2)} + + + {user.name} +
+
+ {user.email} + +
+ {formatDistanceToNow(new Date(user.createdAt), { + addSuffix: true, + })} +
+
+ + + + + + + + setUserToDelete({ + id: user.id, + email: user.email, + name: user.name, + }) + } + > + + Delete Permanently + + + + +
+ ))} +
+
+
+
+ + !open && setUserToDelete(null)} + > + + + Permanently delete user? + +
+

+ This will permanently delete{" "} + {userToDelete?.name} ({userToDelete?.email}) + and all their data. +

+

+ This action cannot be undone. +

+
+
+
+ + Cancel + + {deleteMutation.isPending ? ( + + ) : null} + Delete Permanently + + +
+
+ + ); +} diff --git a/apps/admin/src/app/(dashboard)/users/components/UsersTable/index.ts b/apps/admin/src/app/(dashboard)/users/components/UsersTable/index.ts new file mode 100644 index 00000000000..51ffb31e008 --- /dev/null +++ b/apps/admin/src/app/(dashboard)/users/components/UsersTable/index.ts @@ -0,0 +1 @@ +export { UsersTable } from "./UsersTable"; diff --git a/apps/admin/src/app/(dashboard)/users/deleted/components/DeletedUsersTable/DeletedUsersTable.tsx b/apps/admin/src/app/(dashboard)/users/deleted/components/DeletedUsersTable/DeletedUsersTable.tsx new file mode 100644 index 00000000000..c137fbb1ab3 --- /dev/null +++ b/apps/admin/src/app/(dashboard)/users/deleted/components/DeletedUsersTable/DeletedUsersTable.tsx @@ -0,0 +1,296 @@ +"use client"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@superset/ui/alert-dialog"; +import { Avatar, AvatarFallback, AvatarImage } from "@superset/ui/avatar"; +import { Button } from "@superset/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@superset/ui/card"; +import { toast } from "@superset/ui/sonner"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@superset/ui/table"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { formatDistanceToNow } from "date-fns"; +import { useState } from "react"; +import { LuLoaderCircle, LuRotateCcw, LuTrash2, LuUserX } from "react-icons/lu"; + +import { useTRPC } from "@/trpc/react"; + +export function DeletedUsersTable() { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const { data, isLoading, error } = useQuery( + trpc.admin.listDeletedUsers.queryOptions(), + ); + + const [userToDelete, setUserToDelete] = useState<{ + id: string; + email: string; + name: string; + } | null>(null); + + const restoreMutation = useMutation( + trpc.admin.restoreUser.mutationOptions({ + onSuccess: (_, _variables) => { + queryClient.invalidateQueries({ + queryKey: trpc.admin.listActiveUsers.queryKey(), + }); + queryClient.invalidateQueries({ + queryKey: trpc.admin.listDeletedUsers.queryKey(), + }); + toast.success("User restored successfully"); + }, + onError: (error) => { + toast.error(`Failed to restore user: ${error.message}`); + }, + }), + ); + + const permanentDeleteMutation = useMutation( + trpc.admin.permanentlyDeleteUser.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: trpc.admin.listDeletedUsers.queryKey(), + }); + toast.success(`${userToDelete?.name} has been permanently deleted`); + setUserToDelete(null); + }, + onError: (error) => { + toast.error(`Failed to delete user: ${error.message}`); + }, + }), + ); + + const handlePermanentDelete = () => { + if (!userToDelete) return; + permanentDeleteMutation.mutate({ userId: userToDelete.id }); + }; + + if (isLoading) { + return ( + + + + + + ); + } + + if (error) { + return ( + + +
+ +
+

Failed to load deleted users

+

+ {error.message || "An error occurred while fetching deleted users"} +

+
+
+ ); + } + + if (!data || data.length === 0) { + return ( + + + +

No deleted users

+

+ Users that are soft-deleted will appear here +

+
+
+ ); + } + + return ( + <> + + + Deleted Users + + {data.length} user{data.length !== 1 ? "s" : ""} queued for deletion + + + + + + + User + Email + Deleted + Actions + + + + {data.map((user) => { + const deletedAt = user.deletedAt + ? new Date(user.deletedAt) + : null; + const daysSinceDeleted = deletedAt + ? Math.floor( + (Date.now() - deletedAt.getTime()) / + (1000 * 60 * 60 * 24), + ) + : 0; + const isOverdue = daysSinceDeleted > 30; + + return ( + + +
+ + + + {user.name + .split(" ") + .map((n) => n[0]) + .join("") + .toUpperCase() + .slice(0, 2)} + + + {user.name} +
+
+ {user.email} + +
+
+ {deletedAt + ? formatDistanceToNow(deletedAt, { + addSuffix: true, + }) + : "-"} +
+ {deletedAt && ( +
+ {daysSinceDeleted} day + {daysSinceDeleted !== 1 ? "s" : ""} ago + {isOverdue && " (overdue)"} +
+ )} +
+
+ +
+ + +
+
+
+ ); + })} +
+
+
+
+ + !open && setUserToDelete(null)} + > + + + Permanently delete user? + +
+

+ This will permanently delete{" "} + {userToDelete?.name} ({userToDelete?.email}) + and all their data including: +

+
    +
  • All user data
  • +
  • All associated records
  • +
  • Their Clerk account
  • +
+

+ This action cannot be undone. +

+
+
+
+ + Cancel + + {permanentDeleteMutation.isPending ? ( + + ) : null} + Delete Permanently + + +
+
+ + ); +} diff --git a/apps/admin/src/app/(dashboard)/users/deleted/components/DeletedUsersTable/index.ts b/apps/admin/src/app/(dashboard)/users/deleted/components/DeletedUsersTable/index.ts new file mode 100644 index 00000000000..3b5567553fb --- /dev/null +++ b/apps/admin/src/app/(dashboard)/users/deleted/components/DeletedUsersTable/index.ts @@ -0,0 +1 @@ +export { DeletedUsersTable } from "./DeletedUsersTable"; diff --git a/apps/admin/src/app/(dashboard)/users/deleted/page.tsx b/apps/admin/src/app/(dashboard)/users/deleted/page.tsx new file mode 100644 index 00000000000..9d188ff248f --- /dev/null +++ b/apps/admin/src/app/(dashboard)/users/deleted/page.tsx @@ -0,0 +1,16 @@ +import { DeletedUsersTable } from "./components/DeletedUsersTable"; + +export default function DeletedUsersPage() { + return ( +
+
+

Deleted Users

+

+ Manage users queued for deletion. Users can be restored or permanently + deleted. +

+
+ +
+ ); +} diff --git a/apps/admin/src/app/(dashboard)/users/page.tsx b/apps/admin/src/app/(dashboard)/users/page.tsx new file mode 100644 index 00000000000..1e221a05f07 --- /dev/null +++ b/apps/admin/src/app/(dashboard)/users/page.tsx @@ -0,0 +1,15 @@ +import { UsersTable } from "./components/UsersTable"; + +export default function UsersPage() { + return ( +
+
+

Users

+

+ View all registered users in the platform +

+
+ +
+ ); +} diff --git a/apps/admin/src/app/layout.tsx b/apps/admin/src/app/layout.tsx index 47fe52f50ca..398e8320152 100644 --- a/apps/admin/src/app/layout.tsx +++ b/apps/admin/src/app/layout.tsx @@ -1,13 +1,27 @@ +import { ClerkProvider } from "@clerk/nextjs"; import { Toaster } from "@superset/ui/sonner"; import { cn } from "@superset/ui/utils"; -import { GeistMono } from "geist/font/mono"; -import { GeistSans } from "geist/font/sans"; import type { Metadata, Viewport } from "next"; +import { IBM_Plex_Mono, Inter } from "next/font/google"; + +import { env } from "@/env"; import "./globals.css"; import { Providers } from "./providers"; +const ibmPlexMono = IBM_Plex_Mono({ + weight: ["300", "400", "500"], + subsets: ["latin"], + variable: "--font-ibm-plex-mono", +}); + +const inter = Inter({ + weight: ["300", "400", "500"], + subsets: ["latin"], + variable: "--font-inter", +}); + export const metadata: Metadata = { title: "Superset Admin", description: "Admin dashboard for Superset", @@ -26,19 +40,21 @@ export default function RootLayout({ children: React.ReactNode; }) { return ( - - - - {children} - - - - + + + + + {children} + + + + + ); } diff --git a/apps/admin/src/app/providers.tsx b/apps/admin/src/app/providers.tsx index 4e0d68bfc9e..c28c8dc69c0 100644 --- a/apps/admin/src/app/providers.tsx +++ b/apps/admin/src/app/providers.tsx @@ -1,5 +1,6 @@ "use client"; +import { THEME_STORAGE_KEY } from "@superset/shared/constants"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { ThemeProvider } from "next-themes"; @@ -10,9 +11,10 @@ export function Providers({ children }: { children: React.ReactNode }) { {children} diff --git a/apps/admin/src/env.ts b/apps/admin/src/env.ts index 95217b2c608..8c8857439c1 100644 --- a/apps/admin/src/env.ts +++ b/apps/admin/src/env.ts @@ -11,22 +11,25 @@ export const env = createEnv({ }, server: { - // Database (needed by @superset/trpc dependency) DATABASE_URL: z.string().url(), DATABASE_URL_UNPOOLED: z.string().url(), - // Mock auth - optional, if not set user is unauthenticated - MOCK_USER_ID: z.string().uuid().optional(), + CLERK_SECRET_KEY: z.string(), }, client: { NEXT_PUBLIC_API_URL: z.string().url(), NEXT_PUBLIC_WEB_URL: z.string().url(), + NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: z.string(), + NEXT_PUBLIC_COOKIE_DOMAIN: z.string(), }, experimental__runtimeEnv: { NODE_ENV: process.env.NODE_ENV, NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL, NEXT_PUBLIC_WEB_URL: process.env.NEXT_PUBLIC_WEB_URL, + NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: + process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY, + NEXT_PUBLIC_COOKIE_DOMAIN: process.env.NEXT_PUBLIC_COOKIE_DOMAIN, }, skipValidation: !!process.env.SKIP_ENV_VALIDATION, diff --git a/apps/admin/src/lib/auth/client.tsx b/apps/admin/src/lib/auth/client.tsx deleted file mode 100644 index 2548156f5ec..00000000000 --- a/apps/admin/src/lib/auth/client.tsx +++ /dev/null @@ -1,74 +0,0 @@ -"use client"; - -import { - createContext, - type ReactNode, - useCallback, - useContext, - useMemo, -} from "react"; - -import type { AuthState, User } from "./types"; - -const AuthContext = createContext(null); - -interface AuthProviderProps { - children: ReactNode; - user: User; -} - -/** - * Mock auth provider for client components. - * Receives user from server component and provides it to children. - * - * When real auth (Clerk) is added, replace with ClerkProvider. - */ -export function AuthProvider({ children, user }: AuthProviderProps) { - const value = useMemo( - () => ({ - user, - isLoaded: true, - isSignedIn: true, - }), - [user], - ); - - return {children}; -} - -/** - * Hook to access auth state in client components. - * - * When real auth (Clerk) is added, replace with useAuth() from @clerk/nextjs. - */ -export function useAuth(): AuthState { - const context = useContext(AuthContext); - - if (!context) { - throw new Error("useAuth must be used within an AuthProvider"); - } - - return context; -} - -/** - * Hook to access the current user in client components. - * Returns null if not signed in. - */ -export function useUser(): User | null { - const { user } = useAuth(); - return user; -} - -/** - * Mock sign out function. - * When real auth is added, this will call Clerk's signOut. - */ -export function useSignOut() { - const signOut = useCallback(() => { - // Mock: redirect to web app - window.location.href = process.env.NEXT_PUBLIC_WEB_URL || "/"; - }, []); - - return { signOut }; -} diff --git a/apps/admin/src/lib/auth/index.ts b/apps/admin/src/lib/auth/index.ts deleted file mode 100644 index 71b3347d5b4..00000000000 --- a/apps/admin/src/lib/auth/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -// Re-export types (safe for both client and server) - -// Re-export client utilities (safe for client components) -export { AuthProvider, useAuth, useSignOut, useUser } from "./client"; -export type { AuthState, User } from "./types"; - -// NOTE: Server utilities must be imported directly: -// import { currentUser } from "@/lib/auth/server"; diff --git a/apps/admin/src/lib/auth/server.ts b/apps/admin/src/lib/auth/server.ts deleted file mode 100644 index 9391f718ae1..00000000000 --- a/apps/admin/src/lib/auth/server.ts +++ /dev/null @@ -1,33 +0,0 @@ -import "server-only"; - -import { createCaller, createTRPCContext } from "@superset/trpc"; - -import type { User } from "./types"; - -/** - * Get the current user on the server. - * Uses tRPC caller to fetch user from DB. - * - * Note: The proxy already validates auth and domain access, - * so this primarily exists to get user data for display. - * - * Returns null if not authenticated. - */ -export async function currentUser(): Promise { - try { - const ctx = await createTRPCContext({ headers: new Headers() }); - const caller = createCaller(ctx); - const user = await caller.user.me(); - - if (!user) return null; - - return { - id: user.id, - email: user.email, - name: user.name, - imageUrl: user.avatarUrl ?? undefined, - }; - } catch { - return null; - } -} diff --git a/apps/admin/src/lib/auth/types.ts b/apps/admin/src/lib/auth/types.ts deleted file mode 100644 index cf45f6068dc..00000000000 --- a/apps/admin/src/lib/auth/types.ts +++ /dev/null @@ -1,12 +0,0 @@ -export interface User { - id: string; - email: string; - name: string; - imageUrl?: string; -} - -export interface AuthState { - user: User | null; - isLoaded: boolean; - isSignedIn: boolean; -} diff --git a/apps/admin/src/proxy.ts b/apps/admin/src/proxy.ts index 7733976197e..c3b5bfb88a6 100644 --- a/apps/admin/src/proxy.ts +++ b/apps/admin/src/proxy.ts @@ -1,13 +1,11 @@ +import { clerkMiddleware } from "@clerk/nextjs/server"; +import { db, eq } from "@superset/db"; +import { users } from "@superset/db/schema"; import { COMPANY } from "@superset/shared/constants"; -import { createCaller, createTRPCContext } from "@superset/trpc"; -import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { env } from "./env"; -/** - * Public routes that bypass auth - */ const PUBLIC_ROUTES = ["/ingest", "/monitoring"]; function isPublicRoute(pathname: string): boolean { @@ -16,38 +14,29 @@ function isPublicRoute(pathname: string): boolean { ); } -/** - * Auth proxy - validates user authentication and domain access. - * - * When real auth (Clerk) is added, replace with Clerk's proxy/middleware. - */ -export async function proxy(request: NextRequest) { - const { pathname } = request.nextUrl; +export default clerkMiddleware(async (auth, req) => { + const { pathname } = req.nextUrl; - // Allow public routes if (isPublicRoute(pathname)) { return NextResponse.next(); } - try { - // Create tRPC caller with current session context - const ctx = await createTRPCContext({ headers: request.headers }); - const caller = createCaller(ctx); + const { userId: clerkId } = await auth(); - // Get current user (throws if not authenticated) - const user = await caller.user.me(); + if (!clerkId) { + return NextResponse.redirect(new URL(env.NEXT_PUBLIC_WEB_URL)); + } - // Validate domain access - if (!user?.email.endsWith(COMPANY.emailDomain)) { - return NextResponse.redirect(new URL(env.NEXT_PUBLIC_WEB_URL)); - } + const user = await db.query.users.findFirst({ + where: eq(users.clerkId, clerkId), + }); - return NextResponse.next(); - } catch { - // Not authenticated - redirect to web app + if (!user?.email.endsWith(COMPANY.EMAIL_DOMAIN)) { return NextResponse.redirect(new URL(env.NEXT_PUBLIC_WEB_URL)); } -} + + return NextResponse.next(); +}); export const config = { matcher: [ diff --git a/apps/admin/src/trpc/react.tsx b/apps/admin/src/trpc/react.tsx index 69fafe89316..0c099b7b954 100644 --- a/apps/admin/src/trpc/react.tsx +++ b/apps/admin/src/trpc/react.tsx @@ -47,6 +47,12 @@ export function TRPCReactProvider(props: { children: React.ReactNode }) { headers() { return { "x-trpc-source": "nextjs-react" }; }, + fetch(url, options) { + return fetch(url, { + ...options, + credentials: "include", + }); + }, }), ], }), diff --git a/apps/api/next.config.ts b/apps/api/next.config.ts index 814a700cce8..d48db72682a 100644 --- a/apps/api/next.config.ts +++ b/apps/api/next.config.ts @@ -1,31 +1,9 @@ import type { NextConfig } from "next"; -import { env } from "./src/env"; - -// Allowed origins for CORS -const allowedOrigins = [ - env.NEXT_PUBLIC_WEB_URL, - env.NEXT_PUBLIC_ADMIN_URL, -].filter(Boolean) as string[]; - const config: NextConfig = { reactCompiler: true, typescript: { ignoreBuildErrors: true }, - async headers() { - // Generate CORS headers for each allowed origin - return allowedOrigins.map((origin) => ({ - source: "/api/:path*", - headers: [ - { key: "Access-Control-Allow-Origin", value: origin }, - { key: "Access-Control-Allow-Methods", value: "GET, POST, OPTIONS" }, - { - key: "Access-Control-Allow-Headers", - value: "Content-Type, Authorization, trpc-accept, x-trpc-source", - }, - { key: "Access-Control-Allow-Credentials", value: "true" }, - ], - })); - }, + // CORS is handled dynamically in the route handlers }; export default config; diff --git a/apps/api/package.json b/apps/api/package.json index 9b266ec4af5..752c87ef094 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -11,11 +11,14 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@clerk/backend": "^2.27.0", + "@clerk/nextjs": "^6.36.2", "@superset/db": "workspace:*", "@superset/shared": "workspace:*", "@superset/trpc": "workspace:*", "@t3-oss/env-nextjs": "^0.13.8", "@trpc/server": "^11.7.1", + "@vercel/blob": "^2.0.0", "drizzle-orm": "0.45.1", "next": "^16.0.10", "react": "^19.2.3", diff --git a/apps/api/src/app/api/trpc/[trpc]/route.ts b/apps/api/src/app/api/trpc/[trpc]/route.ts index 44e37e48e6f..96d09181859 100644 --- a/apps/api/src/app/api/trpc/[trpc]/route.ts +++ b/apps/api/src/app/api/trpc/[trpc]/route.ts @@ -1,18 +1,16 @@ -import { appRouter, createTRPCContext } from "@superset/trpc"; +import { appRouter } from "@superset/trpc"; import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; +import { createContext } from "@/trpc/context"; const handler = (req: Request) => fetchRequestHandler({ endpoint: "/api/trpc", req, router: appRouter, - createContext: () => createTRPCContext({ headers: req.headers }), + createContext, onError: ({ path, error }) => { console.error(`❌ tRPC error on ${path ?? ""}:`, error); }, }); -// Preflight requests - CORS headers added by next.config.ts -export const OPTIONS = () => new Response(null, { status: 204 }); - export { handler as GET, handler as POST }; diff --git a/apps/api/src/app/api/webhooks/clerk/route.ts b/apps/api/src/app/api/webhooks/clerk/route.ts new file mode 100644 index 00000000000..1fe958bec9d --- /dev/null +++ b/apps/api/src/app/api/webhooks/clerk/route.ts @@ -0,0 +1,89 @@ +import { verifyWebhook } from "@clerk/backend/webhooks"; +import { db } from "@superset/db/client"; +import { users } from "@superset/db/schema"; +import { put } from "@vercel/blob"; +import { eq } from "drizzle-orm"; + +import { env } from "../../../../env"; + +async function uploadAvatar( + imageUrl: string | undefined, + userId: string, +): Promise { + if (!imageUrl) return null; + + try { + const response = await fetch(imageUrl); + if (!response.ok) return null; + + const blob = await response.blob(); + const { url } = await put(`users/${userId}/avatar.png`, blob, { + access: "public", + token: env.BLOB_READ_WRITE_TOKEN, + }); + return url; + } catch { + return null; + } +} + +export async function POST(req: Request) { + try { + const evt = await verifyWebhook(req, { + signingSecret: env.CLERK_WEBHOOK_SECRET, + }); + + if (evt.type === "user.created" || evt.type === "user.updated") { + const clerkUser = evt.data; + const primaryEmail = clerkUser.email_addresses.find( + (email) => email.id === clerkUser.primary_email_address_id, + )?.email_address; + + if (!primaryEmail) { + return new Response("No primary email", { status: 200 }); + } + + const name = + [clerkUser.first_name, clerkUser.last_name].filter(Boolean).join(" ") || + primaryEmail.split("@")[0] || + "User"; + + // Insert/update user first to get the internal UUID + const [user] = await db + .insert(users) + .values({ + clerkId: clerkUser.id, + email: primaryEmail, + name, + }) + .onConflictDoUpdate({ + target: users.clerkId, + set: { + email: primaryEmail, + name, + }, + }) + .returning({ id: users.id }); + + // Upload avatar using internal UUID, then update user + if (user) { + const avatarUrl = await uploadAvatar(clerkUser.image_url, user.id); + if (avatarUrl) { + await db + .update(users) + .set({ avatarUrl }) + .where(eq(users.id, user.id)); + } + } + } + + if (evt.type === "user.deleted" && evt.data.id) { + await db.delete(users).where(eq(users.clerkId, evt.data.id)); + } + + return new Response("Success", { status: 200 }); + } catch (err) { + console.error("Webhook verification failed:", err); + return new Response("Webhook verification failed", { status: 400 }); + } +} diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts index 23f37b19c63..c6119fcd638 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -5,6 +5,9 @@ export const env = createEnv({ server: { DATABASE_URL: z.string(), DATABASE_URL_UNPOOLED: z.string(), + CLERK_SECRET_KEY: z.string(), + CLERK_WEBHOOK_SECRET: z.string(), + BLOB_READ_WRITE_TOKEN: z.string(), }, client: { NEXT_PUBLIC_WEB_URL: z.string().url(), diff --git a/apps/api/src/proxy.ts b/apps/api/src/proxy.ts new file mode 100644 index 00000000000..480353c355a --- /dev/null +++ b/apps/api/src/proxy.ts @@ -0,0 +1,43 @@ +import { clerkMiddleware } from "@clerk/nextjs/server"; +import { NextResponse } from "next/server"; + +import { env } from "./env"; + +const allowedOrigins = [env.NEXT_PUBLIC_WEB_URL, env.NEXT_PUBLIC_ADMIN_URL]; + +function getCorsHeaders(origin: string | null) { + const isAllowed = origin && allowedOrigins.includes(origin); + return { + "Access-Control-Allow-Origin": isAllowed ? origin : "", + "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", + "Access-Control-Allow-Headers": + "Content-Type, Authorization, x-trpc-source, trpc-accept", + "Access-Control-Allow-Credentials": "true", + }; +} + +export default clerkMiddleware(async (_auth, req) => { + const origin = req.headers.get("origin"); + const corsHeaders = getCorsHeaders(origin); + + // Handle preflight + if (req.method === "OPTIONS") { + return new NextResponse(null, { status: 204, headers: corsHeaders }); + } + + // Add CORS headers to all responses + const response = NextResponse.next(); + for (const [key, value] of Object.entries(corsHeaders)) { + response.headers.set(key, value); + } + return response; +}); + +export const config = { + matcher: [ + // Skip Next.js internals and static files + "/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)", + // Always run for API routes + "/(api|trpc)(.*)", + ], +}; diff --git a/apps/api/src/trpc/context.ts b/apps/api/src/trpc/context.ts new file mode 100644 index 00000000000..fc5a0c5e06a --- /dev/null +++ b/apps/api/src/trpc/context.ts @@ -0,0 +1,7 @@ +import { auth } from "@clerk/nextjs/server"; +import { createTRPCContext } from "@superset/trpc"; + +export const createContext = async () => { + const session = await auth(); + return createTRPCContext({ session }); +}; diff --git a/apps/marketing/package.json b/apps/marketing/package.json index 7f6d0f86a75..dff4a25fbb2 100644 --- a/apps/marketing/package.json +++ b/apps/marketing/package.json @@ -11,8 +11,10 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@clerk/nextjs": "^6.36.2", "@react-three/drei": "^10.7.6", "@react-three/fiber": "^9.4.0", + "@superset/shared": "workspace:*", "@superset/ui": "workspace:*", "@t3-oss/env-nextjs": "^0.13.8", "framer-motion": "^12.23.24", diff --git a/apps/marketing/src/app/components/CTAButtons/CTAButtons.tsx b/apps/marketing/src/app/components/CTAButtons/CTAButtons.tsx new file mode 100644 index 00000000000..8fedcd6f515 --- /dev/null +++ b/apps/marketing/src/app/components/CTAButtons/CTAButtons.tsx @@ -0,0 +1,47 @@ +import { auth } from "@clerk/nextjs/server"; +import { DOWNLOAD_URL_MAC_ARM64 } from "@superset/shared/constants"; +import { Download } from "lucide-react"; + +import { env } from "@/env"; + +export async function CTAButtons() { + const { userId } = await auth(); + + if (userId) { + return ( + + ); + } + + return ( + + ); +} diff --git a/apps/marketing/src/app/components/CTAButtons/index.ts b/apps/marketing/src/app/components/CTAButtons/index.ts new file mode 100644 index 00000000000..09a58f4feff --- /dev/null +++ b/apps/marketing/src/app/components/CTAButtons/index.ts @@ -0,0 +1 @@ +export { CTAButtons } from "./CTAButtons"; diff --git a/apps/marketing/src/app/components/CTASection/CTASection.tsx b/apps/marketing/src/app/components/CTASection/CTASection.tsx index 4ec1964d31c..4e978b2b683 100644 --- a/apps/marketing/src/app/components/CTASection/CTASection.tsx +++ b/apps/marketing/src/app/components/CTASection/CTASection.tsx @@ -13,7 +13,7 @@ export function CTASection() {
-

+

Trusted by engineers from

@@ -27,7 +27,7 @@ export function ClientLogosSection() { @@ -35,7 +35,7 @@ export function ClientLogosSection() { {CLIENT_LOGOS.map((client) => (
{client.logo}
diff --git a/apps/marketing/src/app/components/DownloadButton/DownloadButton.tsx b/apps/marketing/src/app/components/DownloadButton/DownloadButton.tsx index 67d706467dc..b0fb0829521 100644 --- a/apps/marketing/src/app/components/DownloadButton/DownloadButton.tsx +++ b/apps/marketing/src/app/components/DownloadButton/DownloadButton.tsx @@ -1,7 +1,7 @@ "use client"; +import { COMPANY, DOWNLOAD_URL_MAC_ARM64 } from "@superset/shared/constants"; import { HiMiniArrowDownTray, HiMiniClock } from "react-icons/hi2"; -import { DOWNLOAD_URL_MAC_ARM64, GITHUB_REPO_URL } from "@/constants"; import { type DropdownSection, PlatformDropdown } from "../PlatformDropdown"; interface DownloadButtonProps { @@ -23,7 +23,7 @@ export function DownloadButton({ }; const handleBuildFromSource = () => { - window.open(GITHUB_REPO_URL, "_blank"); + window.open(COMPANY.GITHUB_URL, "_blank"); }; const appleIcon = ( @@ -89,7 +89,7 @@ export function DownloadButton({ const trigger = ( diff --git a/apps/marketing/src/app/components/PlatformDropdown/PlatformDropdown.tsx b/apps/marketing/src/app/components/PlatformDropdown/PlatformDropdown.tsx index 3a95f7f0859..9dacc3652d0 100644 --- a/apps/marketing/src/app/components/PlatformDropdown/PlatformDropdown.tsx +++ b/apps/marketing/src/app/components/PlatformDropdown/PlatformDropdown.tsx @@ -40,15 +40,15 @@ export function PlatformDropdown({ {trigger} {sections.map((section, sectionIndex) => (
{sectionIndex > 0 && ( -
+
)} {section.title && ( -

+

{section.title}

)} @@ -62,14 +62,14 @@ export function PlatformDropdown({ {item.variant === "primary" ? (