diff --git a/.env.example b/.env.example index a8188822f..e516015f6 100644 --- a/.env.example +++ b/.env.example @@ -28,11 +28,9 @@ NEXT_PUBLIC_MARKETING_URL=http://localhost:3002 NEXT_PUBLIC_DOCS_URL=http://localhost:3004 # ----------------------------------------------------------------------------- -# Clerk Auth +# Better Auth # ----------------------------------------------------------------------------- -NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY= -CLERK_SECRET_KEY= -CLERK_WEBHOOK_SECRET= +BETTER_AUTH_SECRET= NEXT_PUBLIC_COOKIE_DOMAIN=localhost # ----------------------------------------------------------------------------- @@ -54,7 +52,6 @@ BLOB_READ_WRITE_TOKEN= NEXT_PUBLIC_POSTHOG_KEY= POSTHOG_API_KEY= POSTHOG_PROJECT_ID= -DESKTOP_AUTH_SECRET= # ----------------------------------------------------------------------------- # Freestyle diff --git a/.github/workflows/deploy-preview.yml b/.github/workflows/deploy-preview.yml index 3152585e6..f8350265f 100644 --- a/.github/workflows/deploy-preview.yml +++ b/.github/workflows/deploy-preview.yml @@ -170,10 +170,7 @@ jobs: NEXT_PUBLIC_API_URL: https://${{ env.API_ALIAS }} NEXT_PUBLIC_WEB_URL: https://${{ env.WEB_ALIAS }} NEXT_PUBLIC_ADMIN_URL: https://${{ env.ADMIN_ALIAS }} - CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} - CLERK_WEBHOOK_SECRET: ${{ secrets.CLERK_WEBHOOK_SECRET }} - NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }} - DESKTOP_AUTH_SECRET: ${{ secrets.DESKTOP_AUTH_SECRET }} + BETTER_AUTH_SECRET: ${{ secrets.BETTER_AUTH_SECRET }} GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }} GH_CLIENT_ID: ${{ secrets.GH_CLIENT_ID }} @@ -197,16 +194,13 @@ jobs: vercel pull --yes --environment=preview --token=$VERCEL_TOKEN vercel build --token=$VERCEL_TOKEN VERCEL_URL=$(vercel deploy --prebuilt --token=$VERCEL_TOKEN \ - --env CLERK_SECRET_KEY=$CLERK_SECRET_KEY \ - --env CLERK_WEBHOOK_SECRET=$CLERK_WEBHOOK_SECRET \ --env DATABASE_URL=$DATABASE_URL \ --env DATABASE_URL_UNPOOLED=$DATABASE_URL_UNPOOLED \ --env BLOB_READ_WRITE_TOKEN=$BLOB_READ_WRITE_TOKEN \ --env NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL \ --env NEXT_PUBLIC_WEB_URL=$NEXT_PUBLIC_WEB_URL \ --env NEXT_PUBLIC_ADMIN_URL=$NEXT_PUBLIC_ADMIN_URL \ - --env NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=$NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY \ - --env DESKTOP_AUTH_SECRET=$DESKTOP_AUTH_SECRET \ + --env BETTER_AUTH_SECRET=$BETTER_AUTH_SECRET \ --env GOOGLE_CLIENT_ID=$GOOGLE_CLIENT_ID \ --env GOOGLE_CLIENT_SECRET=$GOOGLE_CLIENT_SECRET \ --env GH_CLIENT_ID=$GH_CLIENT_ID \ @@ -292,12 +286,10 @@ jobs: NEXT_PUBLIC_WEB_URL: https://${{ env.WEB_ALIAS }} NEXT_PUBLIC_MARKETING_URL: https://${{ env.MARKETING_ALIAS }} NEXT_PUBLIC_DOCS_URL: https://${{ env.DOCS_ALIAS }} - CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} - NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }} + BETTER_AUTH_SECRET: ${{ secrets.BETTER_AUTH_SECRET }} NEXT_PUBLIC_COOKIE_DOMAIN: ${{ secrets.NEXT_PUBLIC_COOKIE_DOMAIN }} NEXT_PUBLIC_POSTHOG_KEY: ${{ secrets.NEXT_PUBLIC_POSTHOG_KEY }} NEXT_PUBLIC_POSTHOG_HOST: ${{ secrets.NEXT_PUBLIC_POSTHOG_HOST }} - DESKTOP_AUTH_SECRET: ${{ secrets.DESKTOP_AUTH_SECRET }} NEXT_PUBLIC_SENTRY_DSN_WEB: ${{ secrets.NEXT_PUBLIC_SENTRY_DSN_WEB }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} NEXT_PUBLIC_SENTRY_ENVIRONMENT: ${{ vars.NEXT_PUBLIC_SENTRY_ENVIRONMENT }} @@ -305,15 +297,13 @@ jobs: vercel pull --yes --environment=preview --token=$VERCEL_TOKEN vercel build --token=$VERCEL_TOKEN VERCEL_URL=$(vercel deploy --prebuilt --token=$VERCEL_TOKEN \ - --env CLERK_SECRET_KEY=$CLERK_SECRET_KEY \ --env DATABASE_URL=$DATABASE_URL \ --env DATABASE_URL_UNPOOLED=$DATABASE_URL_UNPOOLED \ - --env DESKTOP_AUTH_SECRET=$DESKTOP_AUTH_SECRET \ + --env BETTER_AUTH_SECRET=$BETTER_AUTH_SECRET \ --env NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL \ --env NEXT_PUBLIC_WEB_URL=$NEXT_PUBLIC_WEB_URL \ --env NEXT_PUBLIC_MARKETING_URL=$NEXT_PUBLIC_MARKETING_URL \ --env NEXT_PUBLIC_DOCS_URL=$NEXT_PUBLIC_DOCS_URL \ - --env NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=$NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY \ --env NEXT_PUBLIC_COOKIE_DOMAIN=$NEXT_PUBLIC_COOKIE_DOMAIN \ --env NEXT_PUBLIC_POSTHOG_KEY=$NEXT_PUBLIC_POSTHOG_KEY \ --env NEXT_PUBLIC_POSTHOG_HOST=$NEXT_PUBLIC_POSTHOG_HOST \ @@ -370,8 +360,7 @@ jobs: 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 }} + BETTER_AUTH_SECRET: ${{ secrets.BETTER_AUTH_SECRET }} NEXT_PUBLIC_COOKIE_DOMAIN: ${{ secrets.NEXT_PUBLIC_COOKIE_DOMAIN }} NEXT_PUBLIC_POSTHOG_KEY: ${{ secrets.NEXT_PUBLIC_POSTHOG_KEY }} NEXT_PUBLIC_POSTHOG_HOST: ${{ secrets.NEXT_PUBLIC_POSTHOG_HOST }} @@ -382,10 +371,9 @@ jobs: vercel pull --yes --environment=preview --token=$VERCEL_TOKEN vercel build --token=$VERCEL_TOKEN VERCEL_URL=$(vercel deploy --prebuilt --token=$VERCEL_TOKEN \ - --env CLERK_SECRET_KEY=$CLERK_SECRET_KEY \ + --env BETTER_AUTH_SECRET=$BETTER_AUTH_SECRET \ --env NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL \ --env NEXT_PUBLIC_WEB_URL=$NEXT_PUBLIC_WEB_URL \ - --env NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=$NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY \ --env NEXT_PUBLIC_COOKIE_DOMAIN=$NEXT_PUBLIC_COOKIE_DOMAIN \ --env NEXT_PUBLIC_POSTHOG_KEY=$NEXT_PUBLIC_POSTHOG_KEY \ --env NEXT_PUBLIC_POSTHOG_HOST=$NEXT_PUBLIC_POSTHOG_HOST \ @@ -455,8 +443,7 @@ jobs: DATABASE_URL_UNPOOLED: ${{ env.DATABASE_URL_UNPOOLED }} 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 }} + BETTER_AUTH_SECRET: ${{ secrets.BETTER_AUTH_SECRET }} NEXT_PUBLIC_COOKIE_DOMAIN: ${{ secrets.NEXT_PUBLIC_COOKIE_DOMAIN }} NEXT_PUBLIC_POSTHOG_KEY: ${{ secrets.NEXT_PUBLIC_POSTHOG_KEY }} NEXT_PUBLIC_POSTHOG_HOST: ${{ secrets.NEXT_PUBLIC_POSTHOG_HOST }} @@ -469,12 +456,11 @@ jobs: vercel pull --yes --environment=preview --token=$VERCEL_TOKEN vercel build --token=$VERCEL_TOKEN VERCEL_URL=$(vercel deploy --prebuilt --token=$VERCEL_TOKEN \ - --env CLERK_SECRET_KEY=$CLERK_SECRET_KEY \ --env DATABASE_URL=$DATABASE_URL \ --env DATABASE_URL_UNPOOLED=$DATABASE_URL_UNPOOLED \ + --env BETTER_AUTH_SECRET=$BETTER_AUTH_SECRET \ --env NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL \ --env NEXT_PUBLIC_WEB_URL=$NEXT_PUBLIC_WEB_URL \ - --env NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=$NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY \ --env NEXT_PUBLIC_COOKIE_DOMAIN=$NEXT_PUBLIC_COOKIE_DOMAIN \ --env NEXT_PUBLIC_POSTHOG_KEY=$NEXT_PUBLIC_POSTHOG_KEY \ --env NEXT_PUBLIC_POSTHOG_HOST=$NEXT_PUBLIC_POSTHOG_HOST \ diff --git a/.github/workflows/deploy-production.yml b/.github/workflows/deploy-production.yml index 47e542c92..150188a05 100644 --- a/.github/workflows/deploy-production.yml +++ b/.github/workflows/deploy-production.yml @@ -73,11 +73,8 @@ jobs: NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }} NEXT_PUBLIC_WEB_URL: ${{ secrets.NEXT_PUBLIC_WEB_URL }} NEXT_PUBLIC_ADMIN_URL: ${{ secrets.NEXT_PUBLIC_ADMIN_URL }} - CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} - CLERK_WEBHOOK_SECRET: ${{ secrets.CLERK_WEBHOOK_SECRET }} + BETTER_AUTH_SECRET: ${{ secrets.BETTER_AUTH_SECRET }} BLOB_READ_WRITE_TOKEN: ${{ secrets.BLOB_READ_WRITE_TOKEN }} - NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }} - DESKTOP_AUTH_SECRET: ${{ secrets.DESKTOP_AUTH_SECRET }} GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }} GH_CLIENT_ID: ${{ secrets.GH_CLIENT_ID }} @@ -101,16 +98,13 @@ jobs: vercel pull --yes --environment=production --token=$VERCEL_TOKEN vercel build --prod --token=$VERCEL_TOKEN vercel deploy --prod --prebuilt --token=$VERCEL_TOKEN \ - --env CLERK_SECRET_KEY=$CLERK_SECRET_KEY \ - --env CLERK_WEBHOOK_SECRET=$CLERK_WEBHOOK_SECRET \ --env DATABASE_URL=$DATABASE_URL \ --env DATABASE_URL_UNPOOLED=$DATABASE_URL_UNPOOLED \ --env BLOB_READ_WRITE_TOKEN=$BLOB_READ_WRITE_TOKEN \ --env NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL \ --env NEXT_PUBLIC_WEB_URL=$NEXT_PUBLIC_WEB_URL \ --env NEXT_PUBLIC_ADMIN_URL=$NEXT_PUBLIC_ADMIN_URL \ - --env NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=$NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY \ - --env DESKTOP_AUTH_SECRET=$DESKTOP_AUTH_SECRET \ + --env BETTER_AUTH_SECRET=$BETTER_AUTH_SECRET \ --env GOOGLE_CLIENT_ID=$GOOGLE_CLIENT_ID \ --env GOOGLE_CLIENT_SECRET=$GOOGLE_CLIENT_SECRET \ --env GH_CLIENT_ID=$GH_CLIENT_ID \ @@ -168,12 +162,10 @@ jobs: NEXT_PUBLIC_WEB_URL: ${{ secrets.NEXT_PUBLIC_WEB_URL }} NEXT_PUBLIC_MARKETING_URL: ${{ secrets.NEXT_PUBLIC_MARKETING_URL }} NEXT_PUBLIC_DOCS_URL: ${{ secrets.NEXT_PUBLIC_DOCS_URL }} - CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} - NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }} + BETTER_AUTH_SECRET: ${{ secrets.BETTER_AUTH_SECRET }} NEXT_PUBLIC_COOKIE_DOMAIN: ${{ secrets.NEXT_PUBLIC_COOKIE_DOMAIN }} NEXT_PUBLIC_POSTHOG_KEY: ${{ secrets.NEXT_PUBLIC_POSTHOG_KEY }} NEXT_PUBLIC_POSTHOG_HOST: ${{ secrets.NEXT_PUBLIC_POSTHOG_HOST }} - DESKTOP_AUTH_SECRET: ${{ secrets.DESKTOP_AUTH_SECRET }} NEXT_PUBLIC_SENTRY_DSN_WEB: ${{ secrets.NEXT_PUBLIC_SENTRY_DSN_WEB }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} NEXT_PUBLIC_SENTRY_ENVIRONMENT: ${{ vars.NEXT_PUBLIC_SENTRY_ENVIRONMENT }} @@ -181,15 +173,13 @@ jobs: vercel pull --yes --environment=production --token=$VERCEL_TOKEN vercel build --prod --token=$VERCEL_TOKEN vercel deploy --prod --prebuilt --token=$VERCEL_TOKEN \ - --env CLERK_SECRET_KEY=$CLERK_SECRET_KEY \ --env DATABASE_URL=$DATABASE_URL \ --env DATABASE_URL_UNPOOLED=$DATABASE_URL_UNPOOLED \ - --env DESKTOP_AUTH_SECRET=$DESKTOP_AUTH_SECRET \ + --env BETTER_AUTH_SECRET=$BETTER_AUTH_SECRET \ --env NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL \ --env NEXT_PUBLIC_WEB_URL=$NEXT_PUBLIC_WEB_URL \ --env NEXT_PUBLIC_MARKETING_URL=$NEXT_PUBLIC_MARKETING_URL \ --env NEXT_PUBLIC_DOCS_URL=$NEXT_PUBLIC_DOCS_URL \ - --env NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=$NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY \ --env NEXT_PUBLIC_COOKIE_DOMAIN=$NEXT_PUBLIC_COOKIE_DOMAIN \ --env NEXT_PUBLIC_POSTHOG_KEY=$NEXT_PUBLIC_POSTHOG_KEY \ --env NEXT_PUBLIC_POSTHOG_HOST=$NEXT_PUBLIC_POSTHOG_HOST \ @@ -230,8 +220,7 @@ jobs: VERCEL_PROJECT_ID: ${{ secrets.VERCEL_MARKETING_PROJECT_ID }} NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }} NEXT_PUBLIC_WEB_URL: ${{ secrets.NEXT_PUBLIC_WEB_URL }} - CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} - NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }} + BETTER_AUTH_SECRET: ${{ secrets.BETTER_AUTH_SECRET }} NEXT_PUBLIC_COOKIE_DOMAIN: ${{ secrets.NEXT_PUBLIC_COOKIE_DOMAIN }} NEXT_PUBLIC_POSTHOG_KEY: ${{ secrets.NEXT_PUBLIC_POSTHOG_KEY }} NEXT_PUBLIC_POSTHOG_HOST: ${{ secrets.NEXT_PUBLIC_POSTHOG_HOST }} @@ -242,10 +231,9 @@ jobs: vercel pull --yes --environment=production --token=$VERCEL_TOKEN vercel build --prod --token=$VERCEL_TOKEN vercel deploy --prod --prebuilt --token=$VERCEL_TOKEN \ - --env CLERK_SECRET_KEY=$CLERK_SECRET_KEY \ + --env BETTER_AUTH_SECRET=$BETTER_AUTH_SECRET \ --env NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL \ --env NEXT_PUBLIC_WEB_URL=$NEXT_PUBLIC_WEB_URL \ - --env NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=$NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY \ --env NEXT_PUBLIC_COOKIE_DOMAIN=$NEXT_PUBLIC_COOKIE_DOMAIN \ --env NEXT_PUBLIC_POSTHOG_KEY=$NEXT_PUBLIC_POSTHOG_KEY \ --env NEXT_PUBLIC_POSTHOG_HOST=$NEXT_PUBLIC_POSTHOG_HOST \ @@ -288,8 +276,7 @@ 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 }} - CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} - NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }} + BETTER_AUTH_SECRET: ${{ secrets.BETTER_AUTH_SECRET }} NEXT_PUBLIC_COOKIE_DOMAIN: ${{ secrets.NEXT_PUBLIC_COOKIE_DOMAIN }} NEXT_PUBLIC_POSTHOG_KEY: ${{ secrets.NEXT_PUBLIC_POSTHOG_KEY }} NEXT_PUBLIC_POSTHOG_HOST: ${{ secrets.NEXT_PUBLIC_POSTHOG_HOST }} @@ -302,12 +289,11 @@ jobs: vercel pull --yes --environment=production --token=$VERCEL_TOKEN vercel build --prod --token=$VERCEL_TOKEN vercel deploy --prod --prebuilt --token=$VERCEL_TOKEN \ - --env CLERK_SECRET_KEY=$CLERK_SECRET_KEY \ --env DATABASE_URL=$DATABASE_URL \ --env DATABASE_URL_UNPOOLED=$DATABASE_URL_UNPOOLED \ + --env BETTER_AUTH_SECRET=$BETTER_AUTH_SECRET \ --env NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL \ --env NEXT_PUBLIC_WEB_URL=$NEXT_PUBLIC_WEB_URL \ - --env NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=$NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY \ --env NEXT_PUBLIC_COOKIE_DOMAIN=$NEXT_PUBLIC_COOKIE_DOMAIN \ --env NEXT_PUBLIC_POSTHOG_KEY=$NEXT_PUBLIC_POSTHOG_KEY \ --env NEXT_PUBLIC_POSTHOG_HOST=$NEXT_PUBLIC_POSTHOG_HOST \ diff --git a/apps/admin/package.json b/apps/admin/package.json index c22d46c49..51757481b 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -11,8 +11,8 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@clerk/nextjs": "^6.36.2", "@sentry/nextjs": "^10.32.1", + "@superset/auth": "workspace:*", "@superset/db": "workspace:*", "@superset/shared": "workspace:*", "@superset/trpc": "workspace:*", @@ -23,6 +23,7 @@ "@trpc/client": "^11.7.1", "@trpc/server": "^11.7.1", "@trpc/tanstack-react-query": "^11.7.1", + "better-auth": "^1.4.9", "date-fns": "^4.1.0", "drizzle-orm": "0.45.1", "import-in-the-middle": "2.0.1", diff --git a/apps/admin/src/app/(dashboard)/components/AppSidebar/AppSidebar.tsx b/apps/admin/src/app/(dashboard)/components/AppSidebar/AppSidebar.tsx index 1b39591c8..eb751b99e 100644 --- a/apps/admin/src/app/(dashboard)/components/AppSidebar/AppSidebar.tsx +++ b/apps/admin/src/app/(dashboard)/components/AppSidebar/AppSidebar.tsx @@ -20,7 +20,7 @@ import { SidebarRail, } from "@superset/ui/sidebar"; import { usePathname } from "next/navigation"; -import { LuChevronRight, LuHouse, LuUsers, LuUserX } from "react-icons/lu"; +import { LuChevronRight, LuHouse, LuUsers } from "react-icons/lu"; import { AppSidebarHeader } from "./components/AppSidebarHeader"; import { NavUser } from "./components/NavUser"; @@ -43,11 +43,6 @@ const sections = [ url: "/users", icon: LuUsers, }, - { - title: "Deleted Users", - url: "/users/deleted", - icon: LuUserX, - }, ], }, ]; 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 774cc5e09..83bb8abd3 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,6 +1,6 @@ "use client"; -import { useClerk } from "@clerk/nextjs"; +import { authClient } from "@superset/auth/client"; import type { RouterOutputs } from "@superset/trpc"; import { Avatar, AvatarFallback, AvatarImage } from "@superset/ui/avatar"; import { @@ -25,7 +25,6 @@ import { LuLogOut, LuSettings, } from "react-icons/lu"; - import { env } from "@/env"; export interface NavUserProps { @@ -34,13 +33,22 @@ export interface NavUserProps { export function NavUser({ user }: NavUserProps) { const { isMobile } = useSidebar(); - const { signOut } = useClerk(); const userInitials = user.name .split(" ") .map((name) => name[0]) .join(""); + const handleSignOut = async () => { + await authClient.signOut({ + fetchOptions: { + onSuccess: () => { + window.location.href = env.NEXT_PUBLIC_WEB_URL; + }, + }, + }); + }; + return ( @@ -51,10 +59,7 @@ export function NavUser({ user }: NavUserProps) { className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground" > - + {userInitials} @@ -75,10 +80,7 @@ export function NavUser({ user }: NavUserProps) {
- + {userInitials} @@ -105,9 +107,7 @@ export function NavUser({ user }: NavUserProps) { - signOut({ redirectUrl: env.NEXT_PUBLIC_WEB_URL })} - > + Log out diff --git a/apps/admin/src/app/(dashboard)/components/LeaderboardTable/LeaderboardTable.tsx b/apps/admin/src/app/(dashboard)/components/LeaderboardTable/LeaderboardTable.tsx index 6c7bd5b63..b24cba6e3 100644 --- a/apps/admin/src/app/(dashboard)/components/LeaderboardTable/LeaderboardTable.tsx +++ b/apps/admin/src/app/(dashboard)/components/LeaderboardTable/LeaderboardTable.tsx @@ -23,7 +23,7 @@ interface LeaderboardEntry { userId: string; name: string; email: string; - avatarUrl: string | null; + image: string | null; count: number; } @@ -97,7 +97,7 @@ export function LeaderboardTable({
- + {entry.name .split(" ") diff --git a/apps/admin/src/app/(dashboard)/layout.tsx b/apps/admin/src/app/(dashboard)/layout.tsx index cdf7dd2d5..944d3e521 100644 --- a/apps/admin/src/app/(dashboard)/layout.tsx +++ b/apps/admin/src/app/(dashboard)/layout.tsx @@ -1,3 +1,5 @@ +import { auth } from "@superset/auth/server"; +import { COMPANY } from "@superset/shared/constants"; import { Breadcrumb, BreadcrumbItem, @@ -12,7 +14,10 @@ import { SidebarProvider, SidebarTrigger, } from "@superset/ui/sidebar"; +import { headers } from "next/headers"; +import { redirect } from "next/navigation"; +import { env } from "@/env"; import { api } from "@/trpc/server"; import { AppSidebar } from "./components/AppSidebar"; @@ -22,11 +27,23 @@ export default async function DashboardLayout({ }: { children: React.ReactNode; }) { + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session?.user) { + redirect(env.NEXT_PUBLIC_WEB_URL); + } + + if (!session.user.email?.endsWith(COMPANY.EMAIL_DOMAIN)) { + redirect(env.NEXT_PUBLIC_WEB_URL); + } + const trpc = await api(); const user = await trpc.user.me.query(); if (!user) { - throw new Error("User not found"); + redirect(env.NEXT_PUBLIC_WEB_URL); } 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 index 3ccac15f6..0883614d7 100644 --- a/apps/admin/src/app/(dashboard)/users/components/UsersTable/UsersTable.tsx +++ b/apps/admin/src/app/(dashboard)/users/components/UsersTable/UsersTable.tsx @@ -45,7 +45,7 @@ export function UsersTable() { const trpc = useTRPC(); const queryClient = useQueryClient(); const { data, isLoading, error } = useQuery( - trpc.admin.listActiveUsers.queryOptions(), + trpc.admin.listUsers.queryOptions(), ); const [userToDelete, setUserToDelete] = useState<{ @@ -55,12 +55,12 @@ export function UsersTable() { } | null>(null); const deleteMutation = useMutation( - trpc.admin.permanentlyDeleteUser.mutationOptions({ + trpc.admin.deleteUser.mutationOptions({ onSuccess: () => { queryClient.invalidateQueries({ - queryKey: trpc.admin.listActiveUsers.queryKey(), + queryKey: trpc.admin.listUsers.queryKey(), }); - toast.success(`${userToDelete?.name} has been permanently deleted`); + toast.success(`${userToDelete?.name} has been deleted`); setUserToDelete(null); }, onError: (error) => { @@ -152,7 +152,7 @@ export function UsersTable() {
- + {user.name .split(" ") 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 deleted file mode 100644 index c137fbb1a..000000000 --- a/apps/admin/src/app/(dashboard)/users/deleted/components/DeletedUsersTable/DeletedUsersTable.tsx +++ /dev/null @@ -1,296 +0,0 @@ -"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 deleted file mode 100644 index 3b5567553..000000000 --- a/apps/admin/src/app/(dashboard)/users/deleted/components/DeletedUsersTable/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 9d188ff24..000000000 --- a/apps/admin/src/app/(dashboard)/users/deleted/page.tsx +++ /dev/null @@ -1,16 +0,0 @@ -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/layout.tsx b/apps/admin/src/app/layout.tsx index 2280d3658..5be8712d8 100644 --- a/apps/admin/src/app/layout.tsx +++ b/apps/admin/src/app/layout.tsx @@ -1,11 +1,8 @@ -import { ClerkProvider } from "@clerk/nextjs"; import { Toaster } from "@superset/ui/sonner"; import { cn } from "@superset/ui/utils"; 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"; @@ -40,21 +37,19 @@ export default function RootLayout({ children: React.ReactNode; }) { return ( - - - - - {children} - - - - - + + + + {children} + + + + ); } diff --git a/apps/admin/src/components/PostHogUserIdentifier/PostHogUserIdentifier.tsx b/apps/admin/src/components/PostHogUserIdentifier/PostHogUserIdentifier.tsx index 68f00c993..e70937d4b 100644 --- a/apps/admin/src/components/PostHogUserIdentifier/PostHogUserIdentifier.tsx +++ b/apps/admin/src/components/PostHogUserIdentifier/PostHogUserIdentifier.tsx @@ -1,27 +1,27 @@ "use client"; -import { useUser } from "@clerk/nextjs"; +import { authClient } from "@superset/auth/client"; import { useQuery } from "@tanstack/react-query"; import posthog from "posthog-js"; import { useEffect } from "react"; -import { useTRPC } from "../../trpc/react"; +import { useTRPC } from "@/trpc/react"; export function PostHogUserIdentifier() { - const { isSignedIn } = useUser(); + const { data: session } = authClient.useSession(); const trpc = useTRPC(); const { data: user } = useQuery({ ...trpc.user.me.queryOptions(), - enabled: isSignedIn, + enabled: !!session?.user, }); useEffect(() => { if (user) { posthog.identify(user.id, { email: user.email, name: user.name }); - } else if (isSignedIn === false) { + } else if (!session?.user) { posthog.reset(); } - }, [user, isSignedIn]); + }, [user, session?.user]); return null; } diff --git a/apps/admin/src/env.ts b/apps/admin/src/env.ts index ba90201c8..1c94bbbef 100644 --- a/apps/admin/src/env.ts +++ b/apps/admin/src/env.ts @@ -13,14 +13,13 @@ export const env = createEnv({ server: { DATABASE_URL: z.string().url(), DATABASE_URL_UNPOOLED: z.string().url(), - CLERK_SECRET_KEY: z.string(), + BETTER_AUTH_SECRET: z.string(), SENTRY_AUTH_TOKEN: z.string().optional(), }, 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(), NEXT_PUBLIC_POSTHOG_KEY: z.string(), NEXT_PUBLIC_POSTHOG_HOST: z.string().url(), @@ -34,8 +33,6 @@ export const env = createEnv({ 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, NEXT_PUBLIC_POSTHOG_KEY: process.env.NEXT_PUBLIC_POSTHOG_KEY, NEXT_PUBLIC_POSTHOG_HOST: process.env.NEXT_PUBLIC_POSTHOG_HOST, diff --git a/apps/admin/src/proxy.ts b/apps/admin/src/proxy.ts index 087e49b26..e048fedfb 100644 --- a/apps/admin/src/proxy.ts +++ b/apps/admin/src/proxy.ts @@ -1,47 +1,29 @@ -import { clerkMiddleware } from "@clerk/nextjs/server"; -import { db } from "@superset/db/client"; -import { users } from "@superset/db/schema"; +import { auth } from "@superset/auth/server"; import { COMPANY } from "@superset/shared/constants"; -import { eq } from "drizzle-orm"; +import { headers } from "next/headers"; import { NextResponse } from "next/server"; import { env } from "./env"; -const PUBLIC_ROUTES = ["/ingest", "/monitoring"]; - -function isPublicRoute(pathname: string): boolean { - return PUBLIC_ROUTES.some( - (route) => pathname === route || pathname.startsWith(`${route}/`), - ); -} - -export default clerkMiddleware(async (auth, req) => { - const { pathname } = req.nextUrl; - - if (isPublicRoute(pathname)) { - return NextResponse.next(); - } - - const { userId: clerkId } = await auth(); +export default async function proxy() { + const session = await auth.api.getSession({ + headers: await headers(), + }); - if (!clerkId) { + if (!session?.user) { return NextResponse.redirect(new URL(env.NEXT_PUBLIC_WEB_URL)); } - const user = await db.query.users.findFirst({ - where: eq(users.clerkId, clerkId), - }); - - if (!user?.email.endsWith(COMPANY.EMAIL_DOMAIN)) { + if (!session.user.email.endsWith(COMPANY.EMAIL_DOMAIN)) { return NextResponse.redirect(new URL(env.NEXT_PUBLIC_WEB_URL)); } return NextResponse.next(); -}); +} export const config = { matcher: [ - "/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)", + "/((?!_next|ingest|monitoring|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)", "/(api|trpc)(.*)", ], }; diff --git a/apps/admin/src/trpc/react.tsx b/apps/admin/src/trpc/react.tsx index 95db3b924..b654b409f 100644 --- a/apps/admin/src/trpc/react.tsx +++ b/apps/admin/src/trpc/react.tsx @@ -1,6 +1,5 @@ "use client"; -import { useAuth } from "@clerk/nextjs"; import type { AppRouter } from "@superset/trpc"; import type { QueryClient } from "@tanstack/react-query"; import { QueryClientProvider } from "@tanstack/react-query"; @@ -33,7 +32,6 @@ export type UseTRPC = typeof useTRPC; export function TRPCReactProvider(props: { children: React.ReactNode }) { const queryClient = getQueryClient(); - const { getToken } = useAuth(); const [trpcClient] = useState(() => createTRPCClient({ @@ -46,13 +44,17 @@ export function TRPCReactProvider(props: { children: React.ReactNode }) { httpBatchStreamLink({ transformer: SuperJSON, url: `${env.NEXT_PUBLIC_API_URL}/api/trpc`, - async headers() { - const token = await getToken(); + headers() { return { "x-trpc-source": "nextjs-react", - ...(token ? { Authorization: `Bearer ${token}` } : {}), }; }, + fetch(url, options) { + return fetch(url, { + ...options, + credentials: "include", + }); + }, }), ], }), diff --git a/apps/api/next.config.ts b/apps/api/next.config.ts index 9943f4e41..8de5ba6fc 100644 --- a/apps/api/next.config.ts +++ b/apps/api/next.config.ts @@ -3,7 +3,6 @@ import { withSentryConfig } from "@sentry/nextjs"; import { config as dotenvConfig } from "dotenv"; import type { NextConfig } from "next"; -// Load .env from monorepo root during development if (process.env.NODE_ENV !== "production") { dotenvConfig({ path: join(process.cwd(), "../../.env"), override: true }); } @@ -11,7 +10,6 @@ if (process.env.NODE_ENV !== "production") { const config: NextConfig = { reactCompiler: true, typescript: { ignoreBuildErrors: true }, - // CORS is handled dynamically in the route handlers }; export default withSentryConfig(config, { diff --git a/apps/api/package.json b/apps/api/package.json index 62ccda6d7..023421e13 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -11,11 +11,10 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@clerk/backend": "^2.27.0", - "@clerk/nextjs": "^6.36.2", "@electric-sql/client": "^1.3.1", "@linear/sdk": "^68.1.0", "@sentry/nextjs": "^10.32.1", + "@superset/auth": "workspace:*", "@superset/db": "workspace:*", "@superset/shared": "workspace:*", "@superset/trpc": "workspace:*", @@ -23,6 +22,7 @@ "@trpc/server": "^11.7.1", "@upstash/qstash": "^2.8.4", "@vercel/blob": "^2.0.0", + "better-auth": "^1.4.9", "drizzle-orm": "0.45.1", "import-in-the-middle": "2.0.1", "jose": "^6.1.3", diff --git a/apps/api/src/app/api/auth/[...all]/route.ts b/apps/api/src/app/api/auth/[...all]/route.ts new file mode 100644 index 000000000..e3a90c2d0 --- /dev/null +++ b/apps/api/src/app/api/auth/[...all]/route.ts @@ -0,0 +1,4 @@ +import { auth } from "@superset/auth/server"; +import { toNextJsHandler } from "better-auth/next-js"; + +export const { GET, POST } = toNextJsHandler(auth); diff --git a/apps/api/src/app/api/auth/desktop/connect/route.ts b/apps/api/src/app/api/auth/desktop/connect/route.ts new file mode 100644 index 000000000..fa9ce7918 --- /dev/null +++ b/apps/api/src/app/api/auth/desktop/connect/route.ts @@ -0,0 +1,45 @@ +import { auth } from "@superset/auth/server"; +import { NextResponse } from "next/server"; + +import { env } from "@/env"; + +export async function GET(request: Request) { + const url = new URL(request.url); + const provider = url.searchParams.get("provider"); + const state = url.searchParams.get("state"); + + if (!provider || !state) { + return new Response("Missing provider or state", { status: 400 }); + } + + if (provider !== "google" && provider !== "github") { + return new Response("Invalid provider", { status: 400 }); + } + + const successUrl = new URL(`${env.NEXT_PUBLIC_WEB_URL}/auth/desktop/success`); + successUrl.searchParams.set("desktop_state", state); + + const result = await auth.api.signInSocial({ + body: { + provider, + callbackURL: successUrl.toString(), + }, + asResponse: true, + }); + + const cookies = result.headers.getSetCookie(); + const body = (await result.json()) as { url?: string; redirect?: boolean }; + + if (!body.url) { + return new Response(`Failed to initiate OAuth: ${JSON.stringify(body)}`, { + status: 500, + }); + } + + const response = NextResponse.redirect(body.url); + for (const cookie of cookies) { + response.headers.append("set-cookie", cookie); + } + + return response; +} diff --git a/apps/api/src/app/api/auth/desktop/github/route.ts b/apps/api/src/app/api/auth/desktop/github/route.ts deleted file mode 100644 index 4f95b0968..000000000 --- a/apps/api/src/app/api/auth/desktop/github/route.ts +++ /dev/null @@ -1,207 +0,0 @@ -import { clerkClient } from "@clerk/nextjs/server"; -import { env } from "@/env"; -import { generateTokens } from "../tokens"; - -/** - * GitHub OAuth token response - */ -interface GitHubTokenResponse { - access_token: string; - token_type: string; - scope: string; -} - -/** - * GitHub user response - */ -interface GitHubUser { - id: number; - login: string; - name: string | null; - email: string | null; - avatar_url: string; -} - -/** - * GitHub email response - */ -interface GitHubEmail { - email: string; - primary: boolean; - verified: boolean; - visibility: string | null; -} - -/** - * Exchange GitHub auth code for tokens and create desktop session - * - * POST /api/auth/desktop/github - * Body: { code: string, redirectUri: string } - * Returns: { accessToken, accessTokenExpiresAt, refreshToken, refreshTokenExpiresAt } - */ -export async function POST(request: Request) { - try { - const body = await request.json(); - const { code, redirectUri } = body as { - code: string; - redirectUri: string; - }; - - if (!code || !redirectUri) { - return Response.json( - { error: "Missing code or redirectUri" }, - { status: 400 }, - ); - } - - // Exchange code for access token with GitHub - const tokenResponse = await fetch( - "https://github.com/login/oauth/access_token", - { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - }, - body: JSON.stringify({ - client_id: env.GH_CLIENT_ID, - client_secret: env.GH_CLIENT_SECRET, - code, - redirect_uri: redirectUri, - }), - }, - ); - - if (!tokenResponse.ok) { - const errorData = await tokenResponse.json().catch(() => ({})); - console.error("[auth/github] Token exchange failed:", errorData); - return Response.json({ error: "Token exchange failed" }, { status: 400 }); - } - - const tokenData: GitHubTokenResponse = await tokenResponse.json(); - - if (!tokenData.access_token) { - console.error("[auth/github] No access token in response:", tokenData); - return Response.json( - { error: "No access token received" }, - { status: 400 }, - ); - } - - // Fetch user info from GitHub - const userResponse = await fetch("https://api.github.com/user", { - headers: { - Authorization: `Bearer ${tokenData.access_token}`, - Accept: "application/vnd.github+json", - "X-GitHub-Api-Version": "2022-11-28", - }, - }); - - if (!userResponse.ok) { - console.error("[auth/github] Failed to fetch user info"); - return Response.json( - { error: "Failed to fetch user info" }, - { status: 400 }, - ); - } - - const githubUser: GitHubUser = await userResponse.json(); - - // Always fetch verified email from /user/emails endpoint - // Never trust githubUser.email as it could be unverified - const emailsResponse = await fetch("https://api.github.com/user/emails", { - headers: { - Authorization: `Bearer ${tokenData.access_token}`, - Accept: "application/vnd.github+json", - "X-GitHub-Api-Version": "2022-11-28", - }, - }); - - if (!emailsResponse.ok) { - console.error("[auth/github] Failed to fetch user emails"); - return Response.json( - { error: "Failed to fetch user emails" }, - { status: 400 }, - ); - } - - const emails: GitHubEmail[] = await emailsResponse.json(); - // Only trust verified emails - prefer primary+verified, fallback to any verified - const primaryVerifiedEmail = emails.find((e) => e.primary && e.verified); - const anyVerifiedEmail = emails.find((e) => e.verified); - const email = - primaryVerifiedEmail?.email || anyVerifiedEmail?.email || null; - - if (!email) { - return Response.json( - { error: "No verified email found on GitHub account" }, - { status: 400 }, - ); - } - - // Parse name into first/last - const nameParts = (githubUser.name || "").split(" "); - const firstName = nameParts[0] || undefined; - const lastName = nameParts.slice(1).join(" ") || undefined; - - // Find or create user in Clerk - const clerk = await clerkClient(); - const existingUsers = await clerk.users.getUserList({ - emailAddress: [email], - }); - - let userId: string; - const existingUser = existingUsers.data[0]; - - if (existingUser) { - userId = existingUser.id; - console.log("[auth/github] Found existing user:", userId); - } else { - // Create new user - try { - const newUser = await clerk.users.createUser({ - emailAddress: [email], - firstName, - lastName, - skipPasswordRequirement: true, - }); - userId = newUser.id; - console.log("[auth/github] Created new user:", userId); - - // Mark the email as verified since GitHub already verified it - const emailId = newUser.emailAddresses[0]?.id; - if (emailId) { - await clerk.emailAddresses.updateEmailAddress(emailId, { - verified: true, - }); - console.log("[auth/github] Marked email as verified"); - } - } catch (clerkError: unknown) { - // Log and return detailed Clerk error - const errorDetails = - clerkError && typeof clerkError === "object" && "errors" in clerkError - ? (clerkError as { errors: unknown[] }).errors - : clerkError; - console.error( - "[auth/github] Clerk createUser failed:", - JSON.stringify(errorDetails, null, 2), - ); - return Response.json( - { - error: "Failed to create user account", - details: errorDetails, - }, - { status: 400 }, - ); - } - } - - // Generate access and refresh tokens - const tokens = await generateTokens(userId, email); - - return Response.json(tokens); - } catch (error) { - console.error("[auth/github] Error:", error); - return Response.json({ error: "Internal server error" }, { status: 500 }); - } -} diff --git a/apps/api/src/app/api/auth/desktop/google/route.ts b/apps/api/src/app/api/auth/desktop/google/route.ts deleted file mode 100644 index 622408b47..000000000 --- a/apps/api/src/app/api/auth/desktop/google/route.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { clerkClient } from "@clerk/nextjs/server"; -import { createRemoteJWKSet, jwtVerify } from "jose"; -import { env } from "@/env"; -import { generateTokens } from "../tokens"; - -/** - * Google OAuth token response - */ -interface GoogleTokenResponse { - access_token: string; - expires_in: number; - token_type: string; - scope: string; - id_token: string; - refresh_token?: string; -} - -/** - * Google ID token payload (verified) - */ -interface GoogleIdTokenPayload { - iss: string; - azp: string; - aud: string; - sub: string; - email: string; - email_verified: boolean; - name?: string; - picture?: string; - given_name?: string; - family_name?: string; - iat: number; - exp: number; -} - -// Google's JWKS endpoint - jose handles caching internally -const GOOGLE_JWKS = createRemoteJWKSet( - new URL("https://www.googleapis.com/oauth2/v3/certs"), -); - -/** - * Exchange Google auth code for tokens and create desktop session - * - * POST /api/auth/desktop/google - * Body: { code: string, redirectUri: string } - * Returns: { accessToken, accessTokenExpiresAt, refreshToken, refreshTokenExpiresAt } - */ -export async function POST(request: Request) { - try { - const body = await request.json(); - const { code, redirectUri } = body as { - code: string; - redirectUri: string; - }; - - if (!code || !redirectUri) { - return Response.json( - { error: "Missing code or redirectUri" }, - { status: 400 }, - ); - } - - // Exchange code for tokens with Google - const tokenResponse = await fetch("https://oauth2.googleapis.com/token", { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: new URLSearchParams({ - code, - client_id: env.GOOGLE_CLIENT_ID, - client_secret: env.GOOGLE_CLIENT_SECRET, - redirect_uri: redirectUri, - grant_type: "authorization_code", - }), - }); - - if (!tokenResponse.ok) { - const errorData = await tokenResponse.json().catch(() => ({})); - console.error("[auth/google] Token exchange failed:", errorData); - return Response.json( - { error: errorData.error_description || "Token exchange failed" }, - { status: 400 }, - ); - } - - const googleTokens: GoogleTokenResponse = await tokenResponse.json(); - - // Verify the ID token signature and claims using Google's JWKS - let payload: GoogleIdTokenPayload; - try { - const { payload: verifiedPayload } = await jwtVerify( - googleTokens.id_token, - GOOGLE_JWKS, - { - issuer: ["https://accounts.google.com", "accounts.google.com"], - audience: env.GOOGLE_CLIENT_ID, - }, - ); - payload = verifiedPayload as unknown as GoogleIdTokenPayload; - } catch (jwtError) { - console.error("[auth/google] JWT verification failed:", jwtError); - return Response.json( - { error: "Invalid or expired ID token" }, - { status: 401 }, - ); - } - - if (!payload.email_verified) { - return Response.json({ error: "Email not verified" }, { status: 400 }); - } - - // Find or create user in Clerk - const clerk = await clerkClient(); - const existingUsers = await clerk.users.getUserList({ - emailAddress: [payload.email], - }); - - let userId: string; - const existingUser = existingUsers.data[0]; - - if (existingUser) { - userId = existingUser.id; - console.log("[auth/google] Found existing user:", userId); - } else { - // Create new user - try { - const newUser = await clerk.users.createUser({ - emailAddress: [payload.email], - firstName: payload.given_name, - lastName: payload.family_name, - skipPasswordRequirement: true, - }); - userId = newUser.id; - console.log("[auth/google] Created new user:", userId); - - // Mark the email as verified since Google already verified it - const emailId = newUser.emailAddresses[0]?.id; - if (emailId) { - await clerk.emailAddresses.updateEmailAddress(emailId, { - verified: true, - }); - console.log("[auth/google] Marked email as verified"); - } - } catch (clerkError: unknown) { - // Log and return detailed Clerk error - const errorDetails = - clerkError && typeof clerkError === "object" && "errors" in clerkError - ? (clerkError as { errors: unknown[] }).errors - : clerkError; - console.error( - "[auth/google] Clerk createUser failed:", - JSON.stringify(errorDetails, null, 2), - ); - return Response.json( - { - error: "Failed to create user account", - details: errorDetails, - }, - { status: 400 }, - ); - } - } - - // Generate access and refresh tokens - const tokens = await generateTokens(userId, payload.email); - - return Response.json(tokens); - } catch (error) { - console.error("[auth/google] Error:", error); - return Response.json({ error: "Internal server error" }, { status: 500 }); - } -} diff --git a/apps/api/src/app/api/auth/desktop/refresh/route.ts b/apps/api/src/app/api/auth/desktop/refresh/route.ts deleted file mode 100644 index 808cc500a..000000000 --- a/apps/api/src/app/api/auth/desktop/refresh/route.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { generateTokens, verifyRefreshToken } from "../tokens"; - -/** - * Refresh access token using a valid refresh token - * - * POST /api/auth/desktop/refresh - * Body: { refreshToken: string } - * Returns: { accessToken, accessTokenExpiresAt, refreshToken, refreshTokenExpiresAt } - * - * This endpoint allows the desktop app to get new tokens without - * requiring the user to re-authenticate through Google OAuth. - */ -export async function POST(request: Request) { - try { - const body = await request.json(); - const { refreshToken } = body as { refreshToken: string }; - - if (!refreshToken) { - return Response.json({ error: "Missing refresh token" }, { status: 400 }); - } - - // Verify the refresh token - const tokenData = await verifyRefreshToken(refreshToken); - - if (!tokenData) { - return Response.json( - { error: "Invalid or expired refresh token" }, - { status: 401 }, - ); - } - - // Generate new tokens (rotate both access and refresh tokens) - const tokens = await generateTokens(tokenData.userId, tokenData.email); - - console.log("[auth/refresh] Tokens refreshed for user:", tokenData.userId); - - return Response.json(tokens); - } catch (error) { - console.error("[auth/refresh] Error:", error); - return Response.json({ error: "Internal server error" }, { status: 500 }); - } -} diff --git a/apps/api/src/app/api/auth/desktop/tokens.ts b/apps/api/src/app/api/auth/desktop/tokens.ts deleted file mode 100644 index f3aff4065..000000000 --- a/apps/api/src/app/api/auth/desktop/tokens.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { jwtVerify, SignJWT } from "jose"; -import { env } from "@/env"; - -// Token expiration times -export const ACCESS_TOKEN_EXPIRY = 60 * 60 * 1000; // 1 hour -export const REFRESH_TOKEN_EXPIRY = 30 * 24 * 60 * 60 * 1000; // 30 days - -/** - * Get the secret key for signing/verifying tokens - */ -export function getSecretKey(): Uint8Array { - return new TextEncoder().encode(env.DESKTOP_AUTH_SECRET); -} - -/** - * Generate access and refresh tokens for a user - */ -export async function generateTokens(userId: string, email: string) { - const secretKey = getSecretKey(); - const now = Date.now(); - const accessTokenExpiresAt = now + ACCESS_TOKEN_EXPIRY; - const refreshTokenExpiresAt = now + REFRESH_TOKEN_EXPIRY; - - // Access token - short-lived, used for API calls - const accessToken = await new SignJWT({ - sub: userId, - email, - type: "access", - }) - .setProtectedHeader({ alg: "HS256" }) - .setIssuedAt() - .setExpirationTime(Math.floor(accessTokenExpiresAt / 1000)) - .setIssuer("superset-desktop") - .sign(secretKey); - - // Refresh token - long-lived, used to get new access tokens - const refreshToken = await new SignJWT({ - sub: userId, - email, - type: "refresh", - }) - .setProtectedHeader({ alg: "HS256" }) - .setIssuedAt() - .setExpirationTime(Math.floor(refreshTokenExpiresAt / 1000)) - .setIssuer("superset-desktop") - .sign(secretKey); - - return { - accessToken, - accessTokenExpiresAt, - refreshToken, - refreshTokenExpiresAt, - }; -} - -/** - * Verify a refresh token and return its payload - */ -export async function verifyRefreshToken(token: string): Promise<{ - userId: string; - email: string; -} | null> { - try { - const secretKey = getSecretKey(); - const { payload } = await jwtVerify(token, secretKey, { - issuer: "superset-desktop", - }); - - // Ensure it's a refresh token - if (payload.type !== "refresh") { - return null; - } - - return { - userId: payload.sub as string, - email: payload.email as string, - }; - } catch { - return null; - } -} diff --git a/apps/api/src/app/api/desktop/version/route.ts b/apps/api/src/app/api/desktop/version/route.ts index c87a24859..01751c8d1 100644 --- a/apps/api/src/app/api/desktop/version/route.ts +++ b/apps/api/src/app/api/desktop/version/route.ts @@ -1,4 +1,4 @@ -const MINIMUM_DESKTOP_VERSION = "0.0.39"; +const MINIMUM_DESKTOP_VERSION = "0.0.48"; /** * Used to force the desktop app to update, in cases where we can't support diff --git a/apps/api/src/app/api/electric/[...path]/route.ts b/apps/api/src/app/api/electric/[...path]/route.ts index 90c9cb64e..2cb3967e4 100644 --- a/apps/api/src/app/api/electric/[...path]/route.ts +++ b/apps/api/src/app/api/electric/[...path]/route.ts @@ -1,46 +1,22 @@ import { ELECTRIC_PROTOCOL_QUERY_PARAMS } from "@electric-sql/client"; -import { db } from "@superset/db/client"; -import { organizationMembers, users } from "@superset/db/schema"; -import { and, eq } from "drizzle-orm"; +import { auth } from "@superset/auth/server"; import { env } from "@/env"; -import { authenticateRequest } from "@/lib/auth"; import { buildWhereClause } from "./utils"; -/** - * Electric SQL Proxy - * - * Forwards shape requests to Electric with organization-based filtering. - * @see https://electric-sql.com/docs/guides/auth#proxy-auth - */ export async function GET(request: Request): Promise { - const clerkUserId = await authenticateRequest(request); - if (!clerkUserId) { - return new Response("Unauthorized", { status: 401 }); - } - - const user = await db.query.users.findFirst({ - where: eq(users.clerkId, clerkUserId), + const sessionData = await auth.api.getSession({ + headers: request.headers, }); - if (!user) { - return new Response("User not found", { status: 401 }); + if (!sessionData?.user) { + return new Response("Unauthorized", { status: 401 }); } - const url = new URL(request.url); - const organizationId = url.searchParams.get("organizationId"); + const organizationId = sessionData.session.activeOrganizationId; if (!organizationId) { - return new Response("Missing organizationId parameter", { status: 400 }); - } - - const membership = await db.query.organizationMembers.findFirst({ - where: and( - eq(organizationMembers.userId, user.id), - eq(organizationMembers.organizationId, organizationId), - ), - }); - if (!membership) { - return new Response("Not a member of this organization", { status: 403 }); + return new Response("No active organization", { status: 400 }); } + const url = new URL(request.url); const originUrl = new URL(env.ELECTRIC_URL); originUrl.searchParams.set("secret", env.ELECTRIC_SECRET); @@ -68,8 +44,6 @@ export async function GET(request: Request): Promise { const response = await fetch(originUrl.toString()); - // Forward headers, but remove content-encoding/length per Electric docs - // (these can cause issues when proxying compressed responses) const headers = new Headers(); response.headers.forEach((value, key) => { const lower = key.toLowerCase(); diff --git a/apps/api/src/app/api/electric/[...path]/utils.ts b/apps/api/src/app/api/electric/[...path]/utils.ts index efd8ca2ff..ba2b1d05f 100644 --- a/apps/api/src/app/api/electric/[...path]/utils.ts +++ b/apps/api/src/app/api/electric/[...path]/utils.ts @@ -1,7 +1,8 @@ import { db } from "@superset/db/client"; import { - organizationMembers, + members, organizations, + repositories, tasks, users, } from "@superset/db/schema"; @@ -11,9 +12,10 @@ import { QueryBuilder } from "drizzle-orm/pg-core"; export type AllowedTable = | "tasks" - | "organization_members" - | "organizations" - | "users"; + | "repositories" + | "auth.members" + | "auth.organizations" + | "auth.users"; interface WhereClause { fragment: string; @@ -40,25 +42,62 @@ export async function buildWhereClause( case "tasks": return build(tasks, tasks.organizationId, organizationId); - case "organization_members": - return build( - organizationMembers, - organizationMembers.organizationId, - organizationId, - ); + case "repositories": + return build(repositories, repositories.organizationId, organizationId); + + case "auth.members": + return build(members, members.organizationId, organizationId); + + case "auth.organizations": { + const userMemberships = await db.query.members.findMany({ + where: eq(members.organizationId, organizationId), + columns: { userId: true }, + }); + + if (userMemberships.length === 0) { + return { fragment: "1 = 0", params: [] }; + } + + const userId = userMemberships[0]?.userId; + if (!userId) { + return { fragment: "1 = 0", params: [] }; + } + + const allUserMemberships = await db.query.members.findMany({ + where: eq(members.userId, userId), + columns: { organizationId: true }, + }); - case "organizations": - return build(organizations, organizations.id, organizationId); + if (allUserMemberships.length === 0) { + return { fragment: "1 = 0", params: [] }; + } + + const orgIds = [ + ...new Set(allUserMemberships.map((m) => m.organizationId)), + ]; + const whereExpr = inArray( + sql`${sql.identifier(organizations.id.name)}`, + orgIds, + ); + const qb = new QueryBuilder(); + const { sql: query, params } = qb + .select() + .from(organizations) + .where(whereExpr) + .toSQL(); + const fragment = query.replace(/^select .* from .* where\s+/i, ""); + return { fragment, params }; + } - case "users": { - const members = await db.query.organizationMembers.findMany({ - where: eq(organizationMembers.organizationId, organizationId), + case "auth.users": { + const orgMembers = await db.query.members.findMany({ + where: eq(members.organizationId, organizationId), columns: { userId: true }, }); - if (members.length === 0) { + if (orgMembers.length === 0) { return { fragment: "1 = 0", params: [] }; } - const userIds = [...new Set(members.map((m) => m.userId))]; + const userIds = [...new Set(orgMembers.map((m) => m.userId))]; const whereExpr = inArray(sql`${sql.identifier(users.id.name)}`, userIds); const qb = new QueryBuilder(); const { sql: query, params } = qb diff --git a/apps/api/src/app/api/integrations/linear/connect/route.ts b/apps/api/src/app/api/integrations/linear/connect/route.ts index 7a76e51a0..6a8d123bb 100644 --- a/apps/api/src/app/api/integrations/linear/connect/route.ts +++ b/apps/api/src/app/api/integrations/linear/connect/route.ts @@ -1,13 +1,15 @@ -import { auth } from "@clerk/nextjs/server"; +import { auth } from "@superset/auth/server"; import { db } from "@superset/db/client"; -import { organizationMembers, users } from "@superset/db/schema"; +import { members } from "@superset/db/schema"; import { and, eq } from "drizzle-orm"; import { env } from "@/env"; export async function GET(request: Request) { - const { userId: clerkUserId } = await auth(); + const session = await auth.api.getSession({ + headers: request.headers, + }); - if (!clerkUserId) { + if (!session?.user) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } @@ -21,18 +23,10 @@ export async function GET(request: Request) { ); } - const user = await db.query.users.findFirst({ - where: eq(users.clerkId, clerkUserId), - }); - - if (!user) { - return Response.json({ error: "User not found" }, { status: 404 }); - } - - const membership = await db.query.organizationMembers.findFirst({ + const membership = await db.query.members.findFirst({ where: and( - eq(organizationMembers.organizationId, organizationId), - eq(organizationMembers.userId, user.id), + eq(members.organizationId, organizationId), + eq(members.userId, session.user.id), ), }); @@ -44,7 +38,7 @@ export async function GET(request: Request) { } const state = Buffer.from( - JSON.stringify({ organizationId, userId: user.id }), + JSON.stringify({ organizationId, userId: session.user.id }), ).toString("base64url"); const linearAuthUrl = new URL("https://linear.app/oauth/authorize"); diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts index a1cfec766..5b0ce9a87 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -10,15 +10,14 @@ export const env = createEnv({ server: { DATABASE_URL: z.string(), DATABASE_URL_UNPOOLED: z.string(), - CLERK_SECRET_KEY: z.string(), ELECTRIC_URL: z.string().url(), ELECTRIC_SECRET: z.string().min(16), BLOB_READ_WRITE_TOKEN: z.string(), - DESKTOP_AUTH_SECRET: z.string().min(32), GOOGLE_CLIENT_ID: z.string().min(1), GOOGLE_CLIENT_SECRET: z.string().min(1), GH_CLIENT_ID: z.string().min(1), GH_CLIENT_SECRET: z.string().min(1), + BETTER_AUTH_SECRET: z.string(), LINEAR_CLIENT_ID: z.string().min(1), LINEAR_CLIENT_SECRET: z.string().min(1), LINEAR_WEBHOOK_SECRET: z.string().min(1), diff --git a/apps/api/src/lib/auth.ts b/apps/api/src/lib/auth.ts deleted file mode 100644 index e883f106c..000000000 --- a/apps/api/src/lib/auth.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { clerkClient } from "@clerk/nextjs/server"; -import { jwtVerify } from "jose"; -import { env } from "@/env"; - -/** - * Verify desktop JWT access token - * Only accepts access tokens (type: "access"), not refresh tokens - */ -async function verifyDesktopToken(token: string): Promise { - try { - const secretKey = new TextEncoder().encode(env.DESKTOP_AUTH_SECRET); - const { payload } = await jwtVerify(token, secretKey, { - issuer: "superset-desktop", - }); - - if (payload.type !== "access") { - return null; - } - - if (typeof payload.sub !== "string") { - return null; - } - - return payload.sub; - } catch { - return null; - } -} - -/** - * Authenticate a request and return the Clerk user ID - * - * Supports: - * 1. Clerk session token (from web app) - * 2. Desktop JWT token (from desktop app) - * - * Returns null if not authenticated. - */ -export async function authenticateRequest( - request: Request, -): Promise { - // Try Clerk auth first - const client = await clerkClient(); - const { isAuthenticated, toAuth } = await client.authenticateRequest(request); - - if (isAuthenticated) { - const auth = toAuth(); - if (auth.userId) { - return auth.userId; - } - } - - // Fall back to desktop JWT - const authHeader = request.headers.get("authorization"); - if (authHeader?.startsWith("Bearer ")) { - const token = authHeader.slice(7); - return verifyDesktopToken(token); - } - - return null; -} diff --git a/apps/api/src/proxy.ts b/apps/api/src/proxy.ts index 9d58e4e26..11ccda86f 100644 --- a/apps/api/src/proxy.ts +++ b/apps/api/src/proxy.ts @@ -1,5 +1,4 @@ -import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server"; -import { NextResponse } from "next/server"; +import { type NextRequest, NextResponse } from "next/server"; import { env } from "./env"; @@ -8,7 +7,6 @@ const allowedOrigins = [ env.NEXT_PUBLIC_ADMIN_URL, env.NODE_ENV === "development" && "http://localhost:5927", ].filter(Boolean); -const isPublicRoute = createRouteMatcher(["/ingest(.*)", "/monitoring(.*)"]); function getCorsHeaders(origin: string | null) { const isAllowed = origin && allowedOrigins.includes(origin); @@ -21,12 +19,7 @@ function getCorsHeaders(origin: string | null) { }; } -export default clerkMiddleware(async (_auth, req) => { - // Allow Sentry and PostHog routes without CORS processing - if (isPublicRoute(req)) { - return NextResponse.next(); - } - +export default function proxy(req: NextRequest) { const origin = req.headers.get("origin"); const corsHeaders = getCorsHeaders(origin); @@ -41,13 +34,11 @@ export default clerkMiddleware(async (_auth, req) => { 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 + "/((?!_next|ingest|monitoring|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)", "/(api|trpc)(.*)", ], }; diff --git a/apps/api/src/trpc/context.ts b/apps/api/src/trpc/context.ts index 98bc13e3b..a3384029b 100644 --- a/apps/api/src/trpc/context.ts +++ b/apps/api/src/trpc/context.ts @@ -1,5 +1,5 @@ +import { auth } from "@superset/auth/server"; import { createTRPCContext } from "@superset/trpc"; -import { authenticateRequest } from "@/lib/auth"; export const createContext = async ({ req, @@ -7,6 +7,8 @@ export const createContext = async ({ req: Request; resHeaders: Headers; }) => { - const userId = await authenticateRequest(req); - return createTRPCContext({ userId }); + const session = await auth.api.getSession({ + headers: req.headers, + }); + return createTRPCContext({ session }); }; diff --git a/apps/desktop/package.json b/apps/desktop/package.json index e2d4eb84d..7ab8e65e1 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -39,6 +39,7 @@ "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-label": "^2.1.8", "@sentry/electron": "^7.5.0", + "@superset/auth": "workspace:*", "@superset/db": "workspace:*", "@superset/local-db": "workspace:*", "@superset/shared": "workspace:*", @@ -122,7 +123,6 @@ "zustand": "^5.0.8" }, "devDependencies": { - "@biomejs/biome": "^2.3.8", "@superset/typescript": "workspace:*", "@tailwindcss/vite": "^4.0.9", "@types/better-sqlite3": "^7.6.13", diff --git a/apps/desktop/src/lib/trpc/routers/auth/index.ts b/apps/desktop/src/lib/trpc/routers/auth/index.ts index ca001efce..3705bad29 100644 --- a/apps/desktop/src/lib/trpc/routers/auth/index.ts +++ b/apps/desktop/src/lib/trpc/routers/auth/index.ts @@ -1,7 +1,6 @@ +import { AUTH_PROVIDERS } from "@superset/shared/constants"; import { observable } from "@trpc/server/observable"; -import type { BrowserWindow } from "electron"; import { authService } from "main/lib/auth"; -import { AUTH_PROVIDERS } from "shared/auth"; import { z } from "zod"; import { publicProcedure, router } from "../.."; @@ -9,18 +8,16 @@ import { publicProcedure, router } from "../.."; * Authentication router for desktop app * Handles sign in/out and state management */ -export const createAuthRouter = (getWindow: () => BrowserWindow | null) => { +export const createAuthRouter = () => { return router({ - /** - * Get current authentication state - */ getState: publicProcedure.query(() => { return authService.getState(); }), - /** - * Subscribe to auth state changes - */ + getAccessToken: publicProcedure.query(() => { + return authService.getAccessToken(); + }), + onStateChange: publicProcedure.subscription(() => { return observable<{ isSignedIn: boolean }>((emit) => { const handler = (state: { isSignedIn: boolean }) => { @@ -41,7 +38,7 @@ export const createAuthRouter = (getWindow: () => BrowserWindow | null) => { /** * Subscribe to access token (for Electric sync in renderer) - * Emits current token on subscribe and again when tokens refresh + * Emits current token on subscribe and when auth state changes */ onAccessToken: publicProcedure.subscription(() => { return observable<{ accessToken: string | null }>((emit) => { @@ -50,6 +47,7 @@ export const createAuthRouter = (getWindow: () => BrowserWindow | null) => { const accessToken = await authService.getAccessToken(); emit.next({ accessToken }); } catch (err) { + console.error("[auth/onAccessToken] Error getting token:", err); emit.error(err instanceof Error ? err : new Error(String(err))); } }; @@ -60,28 +58,45 @@ export const createAuthRouter = (getWindow: () => BrowserWindow | null) => { void emitToken(); - authService.on("tokens-refreshed", handler); authService.on("state-changed", handler); return () => { - authService.off("tokens-refreshed", handler); authService.off("state-changed", handler); }; }); }), - /** - * Sign in with OAuth provider - */ + onSessionChange: publicProcedure.subscription(() => { + return observable>((emit) => { + const handler = ( + session: ReturnType, + ) => { + emit.next(session); + }; + + emit.next(authService.getSession()); + + authService.on("session-changed", handler); + + return () => { + authService.off("session-changed", handler); + }; + }); + }), + + setActiveOrganization: publicProcedure + .input(z.object({ organizationId: z.string() })) + .mutation(async ({ input }) => { + await authService.setActiveOrganization(input.organizationId); + return { success: true }; + }), + signIn: publicProcedure .input(z.object({ provider: z.enum(AUTH_PROVIDERS) })) .mutation(async ({ input }) => { - return authService.signIn(input.provider, getWindow); + return authService.signIn(input.provider); }), - /** - * Sign out - */ signOut: publicProcedure.mutation(async () => { await authService.signOut(); return { success: true }; diff --git a/apps/desktop/src/lib/trpc/routers/index.ts b/apps/desktop/src/lib/trpc/routers/index.ts index 9fd7455f7..66ad3766f 100644 --- a/apps/desktop/src/lib/trpc/routers/index.ts +++ b/apps/desktop/src/lib/trpc/routers/index.ts @@ -23,7 +23,7 @@ import { createWorkspacesRouter } from "./workspaces"; export const createAppRouter = (getWindow: () => BrowserWindow | null) => { return router({ analytics: createAnalyticsRouter(), - auth: createAuthRouter(getWindow), + auth: createAuthRouter(), autoUpdate: createAutoUpdateRouter(), user: createUserRouter(), window: createWindowRouter(getWindow), diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 6afdda383..a9d1e6c56 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -10,7 +10,7 @@ import { DEFAULT_CONFIRM_ON_QUIT, PROTOCOL_SCHEME } from "shared/constants"; import { setupAgentHooks } from "./lib/agent-setup"; import { posthog } from "./lib/analytics"; import { initAppState } from "./lib/app-state"; -import { authService, handleAuthDeepLink, isAuthDeepLink } from "./lib/auth"; +import { authService, parseAuthDeepLink } from "./lib/auth"; import { setupAutoUpdater } from "./lib/auto-updater"; import { localDb } from "./lib/local-db"; import { terminalManager } from "./lib/terminal"; @@ -36,31 +36,15 @@ if (process.defaultApp) { app.setAsDefaultProtocolClient(PROTOCOL_SCHEME); } -/** - * Process a deep link URL for auth - */ async function processDeepLink(url: string): Promise { - if (isAuthDeepLink(url)) { - const result = await handleAuthDeepLink(url); - if ( - result.success && - result.accessToken && - result.accessTokenExpiresAt && - result.refreshToken && - result.refreshTokenExpiresAt && - result.state - ) { - await authService.handleAuthCallback({ - accessToken: result.accessToken, - accessTokenExpiresAt: result.accessTokenExpiresAt, - refreshToken: result.refreshToken, - refreshTokenExpiresAt: result.refreshTokenExpiresAt, - state: result.state, - }); - focusMainWindow(); - } else { - console.error("[main] Auth deep link failed:", result.error); - } + const authParams = parseAuthDeepLink(url); + if (!authParams) return; + + const result = await authService.handleAuthCallback(authParams); + if (result.success) { + focusMainWindow(); + } else { + console.error("[main] Auth deep link failed:", result.error); } } diff --git a/apps/desktop/src/main/lib/api-client.ts b/apps/desktop/src/main/lib/api-client.ts index c8b24b647..457c43733 100644 --- a/apps/desktop/src/main/lib/api-client.ts +++ b/apps/desktop/src/main/lib/api-client.ts @@ -7,6 +7,9 @@ import { authService } from "./auth"; /** * tRPC client for calling the Superset API * Automatically includes the access token in requests + * Handles auth errors vs network errors: + * - 401/403: Token invalid/revoked, clears session + * - Network errors: Preserves session for offline work */ export const apiClient = createTRPCClient({ links: [ @@ -14,7 +17,7 @@ export const apiClient = createTRPCClient({ url: `${env.NEXT_PUBLIC_API_URL}/api/trpc`, transformer: superjson, async headers() { - const token = await authService.getAccessToken(); + const token = authService.getAccessToken(); if (token) { return { Authorization: `Bearer ${token}`, @@ -22,6 +25,23 @@ export const apiClient = createTRPCClient({ } return {}; }, + async fetch(url, options) { + try { + const response = await globalThis.fetch(url, options); + + // Handle auth errors - token was revoked/invalid on server + if (response.status === 401 || response.status === 403) { + console.log("[api-client] Auth error, clearing session"); + await authService.signOut(); + } + + return response; + } catch (error) { + // Network errors - preserve session for offline work + console.log("[api-client] Network error, preserving session", error); + throw error; + } + }, }), ], }); diff --git a/apps/desktop/src/main/lib/auth/auth-service.ts b/apps/desktop/src/main/lib/auth/auth-service.ts deleted file mode 100644 index 87465c97f..000000000 --- a/apps/desktop/src/main/lib/auth/auth-service.ts +++ /dev/null @@ -1,361 +0,0 @@ -import crypto from "node:crypto"; -import { EventEmitter } from "node:events"; -import { type BrowserWindow, shell } from "electron"; -import { env } from "main/env.main"; -import type { AuthProvider, AuthSession, SignInResult } from "shared/auth"; -import { tokenStorage } from "./token-storage"; - -/** - * Store for state parameter (CSRF protection) - */ -const stateStore = new Map(); // state -> timestamp - -/** - * Generate random state for CSRF protection - */ -function generateState(): string { - const state = crypto.randomBytes(32).toString("base64url"); - stateStore.set(state, Date.now()); - // Clean up old states (older than 10 minutes) - const tenMinutesAgo = Date.now() - 10 * 60 * 1000; - for (const [s, timestamp] of stateStore) { - if (timestamp < tenMinutesAgo) { - stateStore.delete(s); - } - } - return state; -} - -/** - * Verify and consume state - */ -function verifyState(state: string): boolean { - if (!stateStore.has(state)) { - return false; - } - stateStore.delete(state); - return true; -} - -interface TokenResponse { - accessToken: string; - accessTokenExpiresAt: number; - refreshToken: string; - refreshTokenExpiresAt: number; -} - -/** - * Type guard to validate token response shape at runtime - */ -function isValidTokenResponse(data: unknown): data is TokenResponse { - if (typeof data !== "object" || data === null) { - return false; - } - const obj = data as Record; - return ( - typeof obj.accessToken === "string" && - obj.accessToken.length > 0 && - typeof obj.accessTokenExpiresAt === "number" && - obj.accessTokenExpiresAt > 0 && - typeof obj.refreshToken === "string" && - obj.refreshToken.length > 0 && - typeof obj.refreshTokenExpiresAt === "number" && - obj.refreshTokenExpiresAt > 0 - ); -} - -/** - * Main authentication service - * Handles direct Google OAuth flow, with token exchange via API - */ -class AuthService extends EventEmitter { - private session: AuthSession | null = null; - - /** - * Initialize auth service - load persisted session - */ - async initialize(): Promise { - const session = await tokenStorage.load(); - - if (!session) { - return; - } - - // Check if access token is expired - if (session.accessTokenExpiresAt < Date.now()) { - console.log("[auth] Access token expired on startup"); - - // Check if refresh token is still valid - if (session.refreshToken && session.refreshTokenExpiresAt > Date.now()) { - console.log("[auth] Attempting to refresh tokens on startup"); - this.session = session; // Temporarily set to allow refresh - const result = await this.refreshTokens(); - - if (result === "success") { - console.log("[auth] Session restored via token refresh"); - return; - } - - if (result === "network_error") { - // Offline - keep session with expired access token - // User can work offline, and we'll refresh when online - console.log("[auth] Offline - keeping session for offline use"); - this.session = session; - return; - } - - // result === "invalid" - tokens are revoked, must clear - } - - // Refresh token invalid/expired or no refresh token - console.log("[auth] Session fully expired, clearing"); - await this.clearSession(); - return; - } - - // Restore session - this.session = session; - console.log("[auth] Session restored"); - } - - /** - * Get current authentication state - */ - getState() { - return { - isSignedIn: !!this.session, - }; - } - - /** - * Get access token for API calls - * Automatically refreshes if access token is expired but refresh token is valid - * Returns null if offline or tokens invalid (caller should handle gracefully) - */ - async getAccessToken(): Promise { - if (!this.session) { - return null; - } - - // Check if access token is expired - if (this.session.accessTokenExpiresAt < Date.now()) { - console.log("[auth] Access token expired, attempting refresh"); - - // Check if refresh token is still valid - if ( - this.session.refreshToken && - this.session.refreshTokenExpiresAt > Date.now() - ) { - const result = await this.refreshTokens(); - if (result === "success") { - return this.session.accessToken; - } - if (result === "network_error") { - // Offline - return null but don't clear session - // User stays signed in, but can't make API calls until online - console.log("[auth] Offline - cannot refresh token"); - return null; - } - // result === "invalid" - fall through to clear session - } - - // Refresh failed or no valid refresh token - console.log("[auth] Session fully expired, clearing"); - await this.clearSession(); - return null; - } - - return this.session.accessToken; - } - - /** - * Refresh tokens using the refresh token - * Returns: 'success' | 'invalid' | 'network_error' - * - 'success': Tokens refreshed successfully - * - 'invalid': Tokens are invalid/revoked (should clear session) - * - 'network_error': Network unavailable (should keep session for offline use) - */ - private async refreshTokens(): Promise< - "success" | "invalid" | "network_error" - > { - if (!this.session?.refreshToken) { - return "invalid"; - } - - try { - const response = await fetch( - `${env.NEXT_PUBLIC_API_URL}/api/auth/desktop/refresh`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - refreshToken: this.session.refreshToken, - }), - }, - ); - - if (!response.ok) { - console.error("[auth] Token refresh failed:", response.status); - // 401/403 means tokens are actually invalid, not a network issue - if (response.status === 401 || response.status === 403) { - return "invalid"; - } - // Other errors (500, etc) - treat as temporary, keep session - return "network_error"; - } - - let data: unknown; - try { - data = await response.json(); - } catch (parseErr) { - console.error( - "[auth] Token refresh JSON parse error:", - parseErr instanceof Error ? parseErr.message : parseErr, - ); - return "invalid"; - } - - // Validate response shape before persisting - if (!isValidTokenResponse(data)) { - console.error( - "[auth] Token refresh response missing required fields:", - data, - ); - return "invalid"; - } - - // Update session with validated tokens - this.session = { - accessToken: data.accessToken, - accessTokenExpiresAt: data.accessTokenExpiresAt, - refreshToken: data.refreshToken, - refreshTokenExpiresAt: data.refreshTokenExpiresAt, - }; - - await tokenStorage.save(this.session); - console.log("[auth] Tokens refreshed successfully"); - this.emit("tokens-refreshed"); - return "success"; - } catch (err) { - // Network errors (offline, DNS failure, etc) - keep session for offline use - const errType = err instanceof Error ? err.constructor.name : typeof err; - const errMsg = err instanceof Error ? err.message : String(err); - console.error(`[auth] Token refresh network error (${errType}):`, errMsg); - return "network_error"; - } - } - - /** - * Sign in with OAuth provider - * Opens system browser directly to provider's OAuth (bypasses Clerk UI) - */ - async signIn( - provider: AuthProvider, - _getWindow: () => BrowserWindow | null, - ): Promise { - try { - // Generate state for CSRF protection - const state = generateState(); - - let authUrl: URL; - - if (provider === "github") { - // Build GitHub OAuth URL - authUrl = new URL("https://github.com/login/oauth/authorize"); - authUrl.searchParams.set("client_id", env.GH_CLIENT_ID); - authUrl.searchParams.set( - "redirect_uri", - `${env.NEXT_PUBLIC_WEB_URL}/api/auth/desktop/github`, - ); - authUrl.searchParams.set("scope", "user:email"); - authUrl.searchParams.set("state", state); - } else { - // Build Google OAuth URL (default) - authUrl = new URL("https://accounts.google.com/o/oauth2/v2/auth"); - authUrl.searchParams.set("client_id", env.GOOGLE_CLIENT_ID); - authUrl.searchParams.set( - "redirect_uri", - `${env.NEXT_PUBLIC_WEB_URL}/api/auth/desktop/google`, - ); - authUrl.searchParams.set("response_type", "code"); - authUrl.searchParams.set("scope", "openid email profile"); - authUrl.searchParams.set("state", state); - // Force account selection every time - authUrl.searchParams.set("prompt", "select_account"); - authUrl.searchParams.set("access_type", "online"); - } - - // Open OAuth flow in system browser - await shell.openExternal(authUrl.toString()); - - console.log("[auth] Opened OAuth flow in browser for:", provider); - return { success: true }; - } catch (err) { - const message = - err instanceof Error ? err.message : "Failed to open browser"; - console.error("[auth] Sign in failed:", message); - return { success: false, error: message }; - } - } - - /** - * Handle auth callback with all tokens from web - */ - async handleAuthCallback(params: { - accessToken: string; - accessTokenExpiresAt: number; - refreshToken: string; - refreshTokenExpiresAt: number; - state: string; - }): Promise { - try { - // Verify state for CSRF protection - if (!verifyState(params.state)) { - return { success: false, error: "Invalid or expired auth session" }; - } - - // Create session with both access and refresh tokens - const session: AuthSession = { - accessToken: params.accessToken, - accessTokenExpiresAt: params.accessTokenExpiresAt, - refreshToken: params.refreshToken, - refreshTokenExpiresAt: params.refreshTokenExpiresAt, - }; - - this.session = session; - await tokenStorage.save(session); - this.emitStateChange(); - - console.log("[auth] Signed in via Google OAuth with refresh token"); - return { success: true }; - } catch (err) { - const message = - err instanceof Error ? err.message : "Failed to complete sign in"; - console.error("[auth] Auth callback handling failed:", message); - await this.clearSession(); - return { success: false, error: message }; - } - } - - /** - * Sign out - clear session - */ - async signOut(): Promise { - await this.clearSession(); - console.log("[auth] Signed out"); - } - - private async clearSession(): Promise { - this.session = null; - await tokenStorage.clear(); - this.emitStateChange(); - } - - private emitStateChange(): void { - this.emit("state-changed", this.getState()); - } -} - -export const authService = new AuthService(); diff --git a/apps/desktop/src/main/lib/auth/auth.ts b/apps/desktop/src/main/lib/auth/auth.ts new file mode 100644 index 000000000..1e92d7925 --- /dev/null +++ b/apps/desktop/src/main/lib/auth/auth.ts @@ -0,0 +1,233 @@ +import crypto from "node:crypto"; +import { EventEmitter } from "node:events"; +import fs from "node:fs/promises"; +import { join } from "node:path"; +import { authClient } from "@superset/auth/client"; +import type { AuthProvider } from "@superset/shared/constants"; +import { PROTOCOL_SCHEMES } from "@superset/shared/constants"; +import { shell } from "electron"; +import { env } from "main/env.main"; +import { SUPERSET_HOME_DIR } from "../app-environment"; +import { decrypt, encrypt } from "./crypto-storage"; + +export interface SignInResult { + success: boolean; + error?: string; +} + +const TOKEN_FILE = join(SUPERSET_HOME_DIR, "auth-token.enc"); +const stateStore = new Map(); + +export function parseAuthDeepLink( + url: string, +): { token: string; expiresAt: string; state: string } | null { + try { + const parsed = new URL(url); + const validProtocols = [ + `${PROTOCOL_SCHEMES.PROD}:`, + `${PROTOCOL_SCHEMES.DEV}:`, + ]; + if (!validProtocols.includes(parsed.protocol)) return null; + if (parsed.host !== "auth" || parsed.pathname !== "/callback") return null; + + const token = parsed.searchParams.get("token"); + const expiresAt = parsed.searchParams.get("expiresAt"); + const state = parsed.searchParams.get("state"); + if (!token || !expiresAt || !state) return null; + return { token, expiresAt, state }; + } catch { + return null; + } +} + +interface StoredAuth { + token: string; + expiresAt: string; +} + +type Session = Awaited>; + +class AuthService extends EventEmitter { + private token: string | null = null; + private expiresAt: Date | null = null; + private session: Session | null = null; + + async initialize(): Promise { + try { + const data = decrypt(await fs.readFile(TOKEN_FILE)); + const parsed: StoredAuth = JSON.parse(data); + this.token = parsed.token; + this.expiresAt = new Date(parsed.expiresAt); + + if (this.isExpired()) { + console.log("[auth] Session expired, clearing"); + await this.signOut(); + } else { + console.log("[auth] Session restored"); + // Fetch session data from API + await this.refreshSession(); + } + } catch { + this.token = null; + this.expiresAt = null; + } + } + + private isExpired(): boolean { + if (!this.expiresAt) return true; + // Consider expired 5 minutes before actual expiry for safety + const bufferMs = 5 * 60 * 1000; + return Date.now() > this.expiresAt.getTime() - bufferMs; + } + + getState() { + const state = { + isSignedIn: !!this.token && !this.isExpired(), + expiresAt: this.expiresAt?.toISOString() ?? null, + }; + console.log("[auth] getState called:", { + hasToken: !!this.token, + isExpired: this.isExpired(), + isSignedIn: state.isSignedIn, + }); + return state; + } + + getAccessToken(): string | null { + const expired = this.isExpired(); + const token = expired ? null : this.token; + console.log("[auth] getAccessToken called:", { + hasToken: !!this.token, + isExpired: expired, + returning: token ? "token" : "null", + }); + return token; + } + + getSession(): Session | null { + return this.session; + } + + private async refreshSession(): Promise { + const token = this.getAccessToken(); + if (!token) { + this.session = null; + return; + } + + try { + const { data: session, error } = await authClient.getSession({ + fetchOptions: { + headers: { + Authorization: `Bearer ${token}`, + }, + }, + }); + + if (error) { + console.error("[auth] Failed to refresh session:", error); + this.session = null; + return; + } + + this.session = session; + this.emit("session-changed", this.session); + } catch (error) { + console.error("[auth] Failed to refresh session:", error); + this.session = null; + } + } + + async setActiveOrganization(organizationId: string): Promise { + const token = this.getAccessToken(); + if (!token) throw new Error("Not authenticated"); + + const { error } = await authClient.organization.setActive({ + organizationId, + fetchOptions: { + headers: { + Authorization: `Bearer ${token}`, + }, + }, + }); + + if (error) { + throw new Error(`Failed to set active organization: ${error.message}`); + } + + // Refresh session to get updated activeOrganizationId + await this.refreshSession(); + } + + async signIn(provider: AuthProvider): Promise { + try { + const state = crypto.randomBytes(32).toString("base64url"); + stateStore.set(state, Date.now()); + + const tenMinutesAgo = Date.now() - 10 * 60 * 1000; + for (const [s, ts] of stateStore) { + if (ts < tenMinutesAgo) stateStore.delete(s); + } + + const connectUrl = new URL( + `${env.NEXT_PUBLIC_API_URL}/api/auth/desktop/connect`, + ); + connectUrl.searchParams.set("provider", provider); + connectUrl.searchParams.set("state", state); + await shell.openExternal(connectUrl.toString()); + return { success: true }; + } catch (err) { + return { + success: false, + error: err instanceof Error ? err.message : "Failed to open browser", + }; + } + } + + async handleAuthCallback(params: { + token: string; + expiresAt: string; + state: string; + }): Promise { + if (!stateStore.has(params.state)) { + return { success: false, error: "Invalid or expired auth session" }; + } + stateStore.delete(params.state); + + this.token = params.token; + this.expiresAt = new Date(params.expiresAt); + + const storedAuth: StoredAuth = { + token: this.token, + expiresAt: params.expiresAt, + }; + await fs.writeFile(TOKEN_FILE, encrypt(JSON.stringify(storedAuth))); + + console.log("[auth] Token saved, fetching session..."); + + // Fetch session data from API before emitting state change + await this.refreshSession(); + + console.log("[auth] Session fetched, emitting state change"); + const state = this.getState(); + console.log("[auth] EMIT state-changed from handleAuthCallback:", state); + this.emit("state-changed", state); + + return { success: true }; + } + + async signOut(): Promise { + console.log("[auth] signOut called"); + this.token = null; + this.expiresAt = null; + this.session = null; + try { + await fs.unlink(TOKEN_FILE); + } catch {} + const state = this.getState(); + console.log("[auth] EMIT state-changed from signOut:", state); + this.emit("state-changed", state); + } +} + +export const authService = new AuthService(); diff --git a/apps/desktop/src/main/lib/auth/crypto-storage.ts b/apps/desktop/src/main/lib/auth/crypto-storage.ts index faf50a98b..faeba16f7 100644 --- a/apps/desktop/src/main/lib/auth/crypto-storage.ts +++ b/apps/desktop/src/main/lib/auth/crypto-storage.ts @@ -16,8 +16,6 @@ const AUTH_TAG_LENGTH = 16; /** * Gets a stable machine identifier for key derivation. - * This provides "good enough" protection for local credential storage - * without requiring OS keychain access. */ function getMachineId(): string { try { diff --git a/apps/desktop/src/main/lib/auth/deep-link-handler.ts b/apps/desktop/src/main/lib/auth/deep-link-handler.ts deleted file mode 100644 index 5f6d864e1..000000000 --- a/apps/desktop/src/main/lib/auth/deep-link-handler.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { PROTOCOL_SCHEMES } from "@superset/shared/constants"; - -/** - * Result of handling an auth deep link - */ -export interface AuthDeepLinkResult { - success: boolean; - accessToken?: string; - accessTokenExpiresAt?: number; - refreshToken?: string; - refreshTokenExpiresAt?: number; - state?: string; - error?: string; -} - -/** - * Handle authentication deep links from web callback - * Format: superset-dev://auth/callback?accessToken=XXX&accessTokenExpiresAt=YYY&refreshToken=ZZZ&refreshTokenExpiresAt=WWW&state=SSS - */ -export async function handleAuthDeepLink( - url: string, -): Promise { - try { - const parsedUrl = new URL(url); - - // Check if this is an auth callback - const isAuthCallback = - parsedUrl.host === "auth" && parsedUrl.pathname === "/callback"; - - if (!isAuthCallback) { - return { success: false, error: "Not an auth callback URL" }; - } - - // Check for error response - const error = parsedUrl.searchParams.get("error"); - if (error) { - const errorDescription = parsedUrl.searchParams.get("error_description"); - return { success: false, error: errorDescription || error }; - } - - // Get all tokens and metadata - const accessToken = parsedUrl.searchParams.get("accessToken"); - const accessTokenExpiresAtStr = parsedUrl.searchParams.get( - "accessTokenExpiresAt", - ); - const refreshToken = parsedUrl.searchParams.get("refreshToken"); - const refreshTokenExpiresAtStr = parsedUrl.searchParams.get( - "refreshTokenExpiresAt", - ); - const state = parsedUrl.searchParams.get("state"); - - if (!accessToken) { - return { success: false, error: "No access token in callback" }; - } - - if (!accessTokenExpiresAtStr) { - return { - success: false, - error: "No access token expiration in callback", - }; - } - - if (!refreshToken) { - return { success: false, error: "No refresh token in callback" }; - } - - if (!refreshTokenExpiresAtStr) { - return { - success: false, - error: "No refresh token expiration in callback", - }; - } - - if (!state) { - return { success: false, error: "No state in callback" }; - } - - const accessTokenExpiresAt = Number.parseInt(accessTokenExpiresAtStr, 10); - if (Number.isNaN(accessTokenExpiresAt)) { - return { - success: false, - error: "Invalid access token expiration in callback", - }; - } - - const refreshTokenExpiresAt = Number.parseInt(refreshTokenExpiresAtStr, 10); - if (Number.isNaN(refreshTokenExpiresAt)) { - return { - success: false, - error: "Invalid refresh token expiration in callback", - }; - } - - return { - success: true, - accessToken, - accessTokenExpiresAt, - refreshToken, - refreshTokenExpiresAt, - state, - }; - } catch (err) { - const message = - err instanceof Error ? err.message : "Failed to process auth callback"; - console.error("[auth] Deep link handling failed:", message); - return { success: false, error: message }; - } -} - -/** - * Check if a URL is an auth-related deep link - */ -export function isAuthDeepLink(url: string): boolean { - try { - const parsedUrl = new URL(url); - // Accept both production and dev protocols - const validProtocols = [ - `${PROTOCOL_SCHEMES.PROD}:`, - `${PROTOCOL_SCHEMES.DEV}:`, - ]; - // Accept "auth" host for callbacks - return ( - validProtocols.includes(parsedUrl.protocol) && parsedUrl.host === "auth" - ); - } catch { - return false; - } -} diff --git a/apps/desktop/src/main/lib/auth/index.ts b/apps/desktop/src/main/lib/auth/index.ts index 1749da60e..91e69aedf 100644 --- a/apps/desktop/src/main/lib/auth/index.ts +++ b/apps/desktop/src/main/lib/auth/index.ts @@ -1,4 +1,2 @@ -export { authService } from "./auth-service"; -export type { AuthDeepLinkResult } from "./deep-link-handler"; -export { handleAuthDeepLink, isAuthDeepLink } from "./deep-link-handler"; -export { tokenStorage } from "./token-storage"; +export type { SignInResult } from "./auth"; +export { authService, parseAuthDeepLink } from "./auth"; diff --git a/apps/desktop/src/main/lib/auth/pkce.ts b/apps/desktop/src/main/lib/auth/pkce.ts deleted file mode 100644 index dd0c6a0ae..000000000 --- a/apps/desktop/src/main/lib/auth/pkce.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { createHash, randomBytes } from "node:crypto"; - -/** - * PKCE (Proof Key for Code Exchange) utilities - * Provides security for OAuth flows by preventing authorization code interception attacks - */ - -/** - * Generate a cryptographically random code verifier - * Must be 43-128 characters, using unreserved URI characters - */ -export function generateCodeVerifier(): string { - // 32 bytes = 43 characters when base64url encoded - return randomBytes(32).toString("base64url"); -} - -/** - * Generate code challenge from code verifier using SHA256 - * This is the S256 method as recommended by RFC 7636 - */ -export function generateCodeChallenge(codeVerifier: string): string { - const hash = createHash("sha256").update(codeVerifier).digest(); - return hash.toString("base64url"); -} - -/** - * Generate a random state value for CSRF protection - */ -export function generateState(): string { - return randomBytes(16).toString("base64url"); -} - -interface PkceData { - codeVerifier: string; - state: string; - createdAt: number; -} - -/** - * PKCE + state storage - * Stores code verifier and state temporarily during OAuth flow - */ -class PkceStore { - private data: PkceData | null = null; - - // Expires after 10 minutes - private readonly EXPIRY_MS = 10 * 60 * 1000; - - /** - * Generate and store a new PKCE pair + state - * Returns the code challenge and state to send to the authorization server - */ - createChallenge(): { codeChallenge: string; state: string } { - const codeVerifier = generateCodeVerifier(); - const state = generateState(); - - this.data = { - codeVerifier, - state, - createdAt: Date.now(), - }; - - return { - codeChallenge: generateCodeChallenge(codeVerifier), - state, - }; - } - - /** - * Retrieve and consume the stored verifier if state matches - * Returns null if expired, not found, or state mismatch - */ - consumeVerifier(state: string): string | null { - if (!this.data) { - return null; - } - - // Check expiry - if (Date.now() - this.data.createdAt > this.EXPIRY_MS) { - this.clear(); - return null; - } - - // Verify state matches (CSRF protection) - if (this.data.state !== state) { - console.warn("[auth] State mismatch - possible CSRF attack"); - this.clear(); - return null; - } - - const verifier = this.data.codeVerifier; - this.clear(); - return verifier; - } - - /** - * Clear stored PKCE state - */ - clear(): void { - this.data = null; - } -} - -export const pkceStore = new PkceStore(); diff --git a/apps/desktop/src/main/lib/auth/token-storage.ts b/apps/desktop/src/main/lib/auth/token-storage.ts deleted file mode 100644 index 4364278dc..000000000 --- a/apps/desktop/src/main/lib/auth/token-storage.ts +++ /dev/null @@ -1,46 +0,0 @@ -import fs from "node:fs/promises"; -import { join } from "node:path"; -import type { AuthSession } from "shared/auth"; -import { SUPERSET_HOME_DIR } from "../app-environment"; -import { decrypt, encrypt } from "./crypto-storage"; - -const SESSION_FILE_NAME = "auth-session.enc"; - -/** - * Securely stores authentication session using machine-derived encryption. - * Session data is encrypted at rest using AES-256-GCM with a key derived - * from the machine's hardware identifier. - */ -class TokenStorage { - private readonly filePath: string; - - constructor() { - this.filePath = join(SUPERSET_HOME_DIR, SESSION_FILE_NAME); - } - - async save(session: AuthSession): Promise { - const encrypted = encrypt(JSON.stringify(session)); - await fs.writeFile(this.filePath, encrypted); - } - - async load(): Promise { - try { - const encrypted = await fs.readFile(this.filePath); - const decrypted = decrypt(encrypted); - return JSON.parse(decrypted) as AuthSession; - } catch { - // File doesn't exist or can't be decrypted - return null; - } - } - - async clear(): Promise { - try { - await fs.unlink(this.filePath); - } catch { - // File doesn't exist, that's fine - } - } -} - -export const tokenStorage = new TokenStorage(); diff --git a/apps/desktop/src/main/windows/main.ts b/apps/desktop/src/main/windows/main.ts index e70fd1cad..dc40dfeeb 100644 --- a/apps/desktop/src/main/windows/main.ts +++ b/apps/desktop/src/main/windows/main.ts @@ -58,6 +58,9 @@ export async function MainWindow() { webPreferences: { preload: join(__dirname, "../preload/index.js"), webviewTag: true, + // Isolate Electron session from system browser cookies + // This ensures desktop uses bearer token auth, not web cookies + partition: "persist:superset", }, }); diff --git a/apps/desktop/src/renderer/contexts/ActiveOrganizationProvider.tsx b/apps/desktop/src/renderer/contexts/ActiveOrganizationProvider.tsx deleted file mode 100644 index 9a407e597..000000000 --- a/apps/desktop/src/renderer/contexts/ActiveOrganizationProvider.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { createContext, type ReactNode, useContext, useState } from "react"; -import { useOrganizations } from "./OrganizationsProvider"; - -const ACTIVE_ORG_KEY = "superset_active_organization_id"; - -interface ActiveOrganizationContextValue { - activeOrganizationId: string; - activeOrganization: ReturnType[number]; - switchOrganization: (orgId: string) => void; -} - -const ActiveOrganizationContext = - createContext(null); - -export function ActiveOrganizationProvider({ - children, -}: { - children: ReactNode; -}) { - const organizations = useOrganizations(); - - const [activeOrganizationId, setActiveOrganizationId] = useState( - () => { - const stored = localStorage.getItem(ACTIVE_ORG_KEY); - const valid = organizations.find((o) => o.id === stored); - return valid?.id ?? organizations[0].id; - }, - ); - - const activeOrganization = organizations.find( - (o) => o.id === activeOrganizationId, - ); - if (!activeOrganization) { - throw new Error(`Active organization not found: ${activeOrganizationId}.`); - } - - const switchOrganization = (newOrgId: string) => { - localStorage.setItem(ACTIVE_ORG_KEY, newOrgId); - setActiveOrganizationId(newOrgId); - }; - - const value: ActiveOrganizationContextValue = { - activeOrganizationId, - activeOrganization, - switchOrganization, - }; - - return ( - - {children} - - ); -} - -export const useActiveOrganization = () => { - const context = useContext(ActiveOrganizationContext); - if (!context) { - throw new Error( - "useActiveOrganization must be used within ActiveOrganizationProvider", - ); - } - return context; -}; diff --git a/apps/desktop/src/renderer/contexts/AppProviders.tsx b/apps/desktop/src/renderer/contexts/AppProviders/AppProviders.tsx similarity index 51% rename from apps/desktop/src/renderer/contexts/AppProviders.tsx rename to apps/desktop/src/renderer/contexts/AppProviders/AppProviders.tsx index 784f7afb0..d78afcd4c 100644 --- a/apps/desktop/src/renderer/contexts/AppProviders.tsx +++ b/apps/desktop/src/renderer/contexts/AppProviders/AppProviders.tsx @@ -1,9 +1,8 @@ import type React from "react"; import { PostHogUserIdentifier } from "renderer/components/PostHogUserIdentifier"; -import { AuthProvider } from "./AuthProvider"; -import { MonacoProvider } from "./MonacoProvider"; -import { PostHogProvider } from "./PostHogProvider"; -import { TRPCProvider } from "./TRPCProvider"; +import { MonacoProvider } from "../MonacoProvider"; +import { PostHogProvider } from "../PostHogProvider"; +import { TRPCProvider } from "../TRPCProvider"; interface AppProvidersProps { children: React.ReactNode; @@ -13,10 +12,8 @@ export function AppProviders({ children }: AppProvidersProps) { return ( - - - {children} - + + {children} ); diff --git a/apps/desktop/src/renderer/contexts/AppProviders/index.ts b/apps/desktop/src/renderer/contexts/AppProviders/index.ts new file mode 100644 index 000000000..9457e31d2 --- /dev/null +++ b/apps/desktop/src/renderer/contexts/AppProviders/index.ts @@ -0,0 +1 @@ +export { AppProviders } from "./AppProviders"; diff --git a/apps/desktop/src/renderer/contexts/AuthProvider.tsx b/apps/desktop/src/renderer/contexts/AuthProvider.tsx deleted file mode 100644 index 39badb360..000000000 --- a/apps/desktop/src/renderer/contexts/AuthProvider.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { createContext, type ReactNode, useContext, useState } from "react"; -import { trpc } from "renderer/lib/trpc"; -import { SignInScreen } from "renderer/screens/sign-in"; - -interface AuthContextValue { - accessToken: string; - isAuthenticated: true; -} - -const AuthContext = createContext(null); - -export function AuthProvider({ children }: { children: ReactNode }) { - const [accessToken, setAccessToken] = useState( - undefined, - ); - - trpc.auth.onAccessToken.useSubscription(undefined, { - onData: (data) => setAccessToken(data.accessToken), - onError: (err) => { - console.error("[AuthProvider] Token subscription error:", err); - }, - }); - - if (accessToken === undefined) { - return null; - } - - if (accessToken === null) { - return ; - } - - const value: AuthContextValue = { - accessToken, - isAuthenticated: true, - }; - - return {children}; -} - -export const useAuth = () => { - const context = useContext(AuthContext); - if (!context) { - throw new Error("useAuth must be used within AuthProvider"); - } - return context; -}; diff --git a/apps/desktop/src/renderer/contexts/CollectionsProvider.tsx b/apps/desktop/src/renderer/contexts/CollectionsProvider.tsx deleted file mode 100644 index 98130c276..000000000 --- a/apps/desktop/src/renderer/contexts/CollectionsProvider.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { - createContext, - type ReactNode, - useContext, - useMemo, - useRef, -} from "react"; -import { type Collections, createCollections } from "renderer/collections"; -import { env } from "renderer/env.renderer"; -import { useActiveOrganization } from "./ActiveOrganizationProvider"; -import { useAuth } from "./AuthProvider"; - -const CollectionsContext = createContext(null); - -export function CollectionsProvider({ children }: { children: ReactNode }) { - const { accessToken } = useAuth(); - const { activeOrganizationId } = useActiveOrganization(); - - // Keep ref to always get current token (handles token refresh without recreating collections) - const accessTokenRef = useRef(accessToken); - accessTokenRef.current = accessToken; - - // Stable map of collections per org (never recreate collections, just cache them) - const collectionsCache = useRef>(new Map()); - - const collections = useMemo(() => { - if (!activeOrganizationId) return null; - - const cached = collectionsCache.current.get(activeOrganizationId); - if (cached) return cached; - - const getHeaders = () => ({ - Authorization: `Bearer ${accessTokenRef.current}`, - }); - const electricUrl = `${env.NEXT_PUBLIC_API_URL}/api/electric/v1/shape?organizationId=${activeOrganizationId}`; - - const newCollections = createCollections({ - orgId: activeOrganizationId, - electricUrl, - apiUrl: env.NEXT_PUBLIC_API_URL, - getHeaders, - }); - - collectionsCache.current.set(activeOrganizationId, newCollections); - return newCollections; - }, [activeOrganizationId]); - - if (!collections) { - return null; - } - - return ( - - {children} - - ); -} - -export const useCollections = () => { - const collections = useContext(CollectionsContext); - if (!collections) { - throw new Error("useCollections must be used within CollectionsProvider"); - } - return collections; -}; diff --git a/apps/desktop/src/renderer/contexts/CollectionsProvider/CollectionsProvider.tsx b/apps/desktop/src/renderer/contexts/CollectionsProvider/CollectionsProvider.tsx new file mode 100644 index 000000000..1728e325f --- /dev/null +++ b/apps/desktop/src/renderer/contexts/CollectionsProvider/CollectionsProvider.tsx @@ -0,0 +1,77 @@ +import { + createContext, + type ReactNode, + useContext, + useMemo, + useState, +} from "react"; +import { trpc } from "../../lib/trpc"; +import { createCollections } from "./collections"; + +type Collections = ReturnType; + +const CollectionsContext = createContext(null); + +export function CollectionsProvider({ children }: { children: ReactNode }) { + const { data: session } = trpc.auth.onSessionChange.useSubscription(); + const { data: tokenData } = trpc.auth.onAccessToken.useSubscription(); + const [error, setError] = useState(null); + + const activeOrgId = session?.session.activeOrganizationId; + const token = tokenData?.accessToken; + + const collections = useMemo(() => { + console.log("[CollectionsProvider] Creating collections with:", { + hasToken: !!token, + activeOrgId, + }); + + if (!token || !activeOrgId) { + console.log( + "[CollectionsProvider] Missing token or activeOrgId, returning null", + ); + return null; + } + + try { + return createCollections({ token, activeOrgId }); + } catch (err) { + console.error("[CollectionsProvider] Failed to create collections:", err); + setError(err instanceof Error ? err : new Error(String(err))); + return null; + } + }, [token, activeOrgId]); + + if (error) { + return ( +
+
+ Failed to initialize collections + {error.message} +
+
+ ); + } + + if (!collections) { + return ( +
+
+
+ ); + } + + return ( + + {children} + + ); +} + +export function useCollections(): Collections { + const context = useContext(CollectionsContext); + if (!context) { + throw new Error("useCollections must be used within CollectionsProvider"); + } + return context; +} diff --git a/apps/desktop/src/renderer/collections/index.ts b/apps/desktop/src/renderer/contexts/CollectionsProvider/collections.ts similarity index 55% rename from apps/desktop/src/renderer/collections/index.ts rename to apps/desktop/src/renderer/contexts/CollectionsProvider/collections.ts index c94b4b1f9..7cbff9ec9 100644 --- a/apps/desktop/src/renderer/collections/index.ts +++ b/apps/desktop/src/renderer/contexts/CollectionsProvider/collections.ts @@ -1,83 +1,68 @@ import { snakeCamelMapper } from "@electric-sql/client"; import type { - SelectOrganizationMember, + SelectMember, + SelectOrganization, SelectRepository, SelectTask, SelectUser, } from "@superset/db/schema"; import type { AppRouter } from "@superset/trpc"; -import { localStorageCollectionOptions } from "@tanstack/db"; import { electricCollectionOptions } from "@tanstack/electric-db-collection"; import { createCollection } from "@tanstack/react-db"; import { createTRPCProxyClient, httpBatchLink } from "@trpc/client"; import superjson from "superjson"; +import { env } from "../../env.renderer"; const columnMapper = snakeCamelMapper(); +const electricUrl = `${env.NEXT_PUBLIC_API_URL}/api/electric/v1/shape`; -const createHttpTrpcClient = ({ - apiUrl, - getHeaders, -}: { - apiUrl: string; - getHeaders: () => Record; -}) => { - return createTRPCProxyClient({ +interface CreateCollectionsParams { + token: string; + activeOrgId: string; +} + +export function createCollections({ + token, + activeOrgId, +}: CreateCollectionsParams) { + const headers = { Authorization: `Bearer ${token}` }; + + const apiClient = createTRPCProxyClient({ links: [ httpBatchLink({ - url: `${apiUrl}/api/trpc`, - headers: getHeaders, + url: `${env.NEXT_PUBLIC_API_URL}/api/trpc`, + headers, transformer: superjson, }), ], }); -}; - -export interface DeviceSetting { - key: string; - value: unknown; -} - -export const createCollections = ({ - orgId, - electricUrl, - apiUrl, - getHeaders, -}: { - orgId: string; - electricUrl: string; - apiUrl: string; - getHeaders: () => Record; -}) => { - const httpTrpcClient = createHttpTrpcClient({ apiUrl, getHeaders }); const tasks = createCollection( electricCollectionOptions({ - id: `tasks-${orgId}`, + id: `tasks-${activeOrgId}`, shapeOptions: { url: electricUrl, params: { table: "tasks", + org: activeOrgId, }, - headers: getHeaders(), + headers, columnMapper, }, getKey: (item) => item.id, - onInsert: async ({ transaction }) => { const item = transaction.mutations[0].modified; - const result = await httpTrpcClient.task.create.mutate(item); + const result = await apiClient.task.create.mutate(item); return { txid: result.txid }; }, - onUpdate: async ({ transaction }) => { const { modified } = transaction.mutations[0]; - const result = await httpTrpcClient.task.update.mutate(modified); + const result = await apiClient.task.update.mutate(modified); return { txid: result.txid }; }, - onDelete: async ({ transaction }) => { const item = transaction.mutations[0].original; - const result = await httpTrpcClient.task.delete.mutate(item.id); + const result = await apiClient.task.delete.mutate(item.id); return { txid: result.txid }; }, }), @@ -85,40 +70,40 @@ export const createCollections = ({ const repositories = createCollection( electricCollectionOptions({ - id: `repositories-${orgId}`, + id: `repositories-${activeOrgId}`, shapeOptions: { url: electricUrl, params: { table: "repositories", + org: activeOrgId, }, - headers: getHeaders(), + headers, columnMapper, }, getKey: (item) => item.id, - onInsert: async ({ transaction }) => { const item = transaction.mutations[0].modified; - const result = await httpTrpcClient.repository.create.mutate(item); + const result = await apiClient.repository.create.mutate(item); return { txid: result.txid }; }, - onUpdate: async ({ transaction }) => { const { modified } = transaction.mutations[0]; - const result = await httpTrpcClient.repository.update.mutate(modified); + const result = await apiClient.repository.update.mutate(modified); return { txid: result.txid }; }, }), ); const members = createCollection( - electricCollectionOptions({ - id: `members-${orgId}`, + electricCollectionOptions({ + id: `members-${activeOrgId}`, shapeOptions: { url: electricUrl, params: { - table: "organization_members", + table: "auth.members", + org: activeOrgId, }, - headers: getHeaders(), + headers, columnMapper, }, getKey: (item) => item.id, @@ -127,34 +112,32 @@ export const createCollections = ({ const users = createCollection( electricCollectionOptions({ - id: `users-${orgId}`, + id: `users-${activeOrgId}`, shapeOptions: { url: electricUrl, params: { - table: "users", + table: "auth.users", + org: activeOrgId, }, - headers: getHeaders(), + headers, columnMapper, }, getKey: (item) => item.id, }), ); - const deviceSettings = createCollection( - localStorageCollectionOptions({ - storageKey: "device-settings", - getKey: (item) => item.key, - storage: localStorage, + const organizations = createCollection( + electricCollectionOptions({ + id: "organizations", + shapeOptions: { + url: electricUrl, + params: { table: "auth.organizations" }, + headers, + columnMapper, + }, + getKey: (item) => item.id, }), ); - return { - tasks, - repositories, - members, - users, - deviceSettings, - }; -}; - -export type Collections = ReturnType; + return { tasks, repositories, members, users, organizations }; +} diff --git a/apps/desktop/src/renderer/contexts/CollectionsProvider/index.ts b/apps/desktop/src/renderer/contexts/CollectionsProvider/index.ts new file mode 100644 index 000000000..8200e98b6 --- /dev/null +++ b/apps/desktop/src/renderer/contexts/CollectionsProvider/index.ts @@ -0,0 +1 @@ +export { CollectionsProvider, useCollections } from "./CollectionsProvider"; diff --git a/apps/desktop/src/renderer/contexts/MonacoProvider.tsx b/apps/desktop/src/renderer/contexts/MonacoProvider/MonacoProvider.tsx similarity index 100% rename from apps/desktop/src/renderer/contexts/MonacoProvider.tsx rename to apps/desktop/src/renderer/contexts/MonacoProvider/MonacoProvider.tsx diff --git a/apps/desktop/src/renderer/contexts/MonacoProvider/index.ts b/apps/desktop/src/renderer/contexts/MonacoProvider/index.ts new file mode 100644 index 000000000..9c0a21f39 --- /dev/null +++ b/apps/desktop/src/renderer/contexts/MonacoProvider/index.ts @@ -0,0 +1,8 @@ +export { + MONACO_EDITOR_OPTIONS, + MonacoProvider, + monaco, + registerSaveAction, + SUPERSET_THEME, + useMonacoReady, +} from "./MonacoProvider"; diff --git a/apps/desktop/src/renderer/contexts/OrganizationsProvider.tsx b/apps/desktop/src/renderer/contexts/OrganizationsProvider/OrganizationsProvider.tsx similarity index 100% rename from apps/desktop/src/renderer/contexts/OrganizationsProvider.tsx rename to apps/desktop/src/renderer/contexts/OrganizationsProvider/OrganizationsProvider.tsx diff --git a/apps/desktop/src/renderer/contexts/OrganizationsProvider/index.ts b/apps/desktop/src/renderer/contexts/OrganizationsProvider/index.ts new file mode 100644 index 000000000..ef167b375 --- /dev/null +++ b/apps/desktop/src/renderer/contexts/OrganizationsProvider/index.ts @@ -0,0 +1,5 @@ +export { + type Organization, + OrganizationsProvider, + useOrganizations, +} from "./OrganizationsProvider"; diff --git a/apps/desktop/src/renderer/contexts/PostHogProvider.tsx b/apps/desktop/src/renderer/contexts/PostHogProvider/PostHogProvider.tsx similarity index 90% rename from apps/desktop/src/renderer/contexts/PostHogProvider.tsx rename to apps/desktop/src/renderer/contexts/PostHogProvider/PostHogProvider.tsx index 489996944..a5cce0f13 100644 --- a/apps/desktop/src/renderer/contexts/PostHogProvider.tsx +++ b/apps/desktop/src/renderer/contexts/PostHogProvider/PostHogProvider.tsx @@ -1,8 +1,7 @@ import { PostHogProvider as PHProvider } from "posthog-js/react"; import type React from "react"; import { useEffect, useState } from "react"; - -import { initPostHog, posthog } from "../lib/posthog"; +import { initPostHog, posthog } from "renderer/lib/posthog"; interface PostHogProviderProps { children: React.ReactNode; diff --git a/apps/desktop/src/renderer/contexts/PostHogProvider/index.ts b/apps/desktop/src/renderer/contexts/PostHogProvider/index.ts new file mode 100644 index 000000000..56caddde4 --- /dev/null +++ b/apps/desktop/src/renderer/contexts/PostHogProvider/index.ts @@ -0,0 +1 @@ +export { PostHogProvider } from "./PostHogProvider"; diff --git a/apps/desktop/src/renderer/contexts/TRPCProvider.tsx b/apps/desktop/src/renderer/contexts/TRPCProvider/TRPCProvider.tsx similarity index 100% rename from apps/desktop/src/renderer/contexts/TRPCProvider.tsx rename to apps/desktop/src/renderer/contexts/TRPCProvider/TRPCProvider.tsx diff --git a/apps/desktop/src/renderer/contexts/TRPCProvider/index.ts b/apps/desktop/src/renderer/contexts/TRPCProvider/index.ts new file mode 100644 index 000000000..080c22d76 --- /dev/null +++ b/apps/desktop/src/renderer/contexts/TRPCProvider/index.ts @@ -0,0 +1 @@ +export { TRPCProvider } from "./TRPCProvider"; diff --git a/apps/desktop/src/renderer/contexts/index.ts b/apps/desktop/src/renderer/contexts/index.ts deleted file mode 100644 index 710daff5f..000000000 --- a/apps/desktop/src/renderer/contexts/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -export { - ActiveOrganizationProvider, - useActiveOrganization, -} from "./ActiveOrganizationProvider"; -export { AppProviders } from "./AppProviders"; -export { CollectionsProvider, useCollections } from "./CollectionsProvider"; -export { - MonacoProvider, - SUPERSET_THEME, - useMonacoReady, -} from "./MonacoProvider"; -export { - type Organization, - OrganizationsProvider, - useOrganizations, -} from "./OrganizationsProvider"; -export { PostHogProvider } from "./PostHogProvider"; -export { TRPCProvider } from "./TRPCProvider"; diff --git a/apps/desktop/src/renderer/index.tsx b/apps/desktop/src/renderer/index.tsx index f532f964a..3d45899fe 100644 --- a/apps/desktop/src/renderer/index.tsx +++ b/apps/desktop/src/renderer/index.tsx @@ -2,17 +2,20 @@ import { initSentry } from "./lib/sentry"; initSentry(); +import React from "react"; import ReactDom from "react-dom/client"; import { ThemedToaster } from "./components/ThemedToaster"; -import { AppProviders } from "./contexts"; +import { AppProviders } from "./contexts/AppProviders"; import { AppRoutes } from "./routes"; import "./globals.css"; ReactDom.createRoot(document.querySelector("app") as HTMLElement).render( - - - - , + + + + + + , ); diff --git a/apps/desktop/src/renderer/screens/main/components/AvatarDropdown/AvatarDropdown.tsx b/apps/desktop/src/renderer/screens/main/components/AvatarDropdown/AvatarDropdown.tsx index 1ac9dacb7..49f6433bd 100644 --- a/apps/desktop/src/renderer/screens/main/components/AvatarDropdown/AvatarDropdown.tsx +++ b/apps/desktop/src/renderer/screens/main/components/AvatarDropdown/AvatarDropdown.tsx @@ -87,7 +87,7 @@ export function AvatarDropdown() { aria-label="User menu" > - + {initials || "?"} diff --git a/apps/desktop/src/renderer/screens/main/components/SettingsView/AccountSettings/AccountSettings.tsx b/apps/desktop/src/renderer/screens/main/components/SettingsView/AccountSettings/AccountSettings.tsx index 9f16c773f..b782d982a 100644 --- a/apps/desktop/src/renderer/screens/main/components/SettingsView/AccountSettings/AccountSettings.tsx +++ b/apps/desktop/src/renderer/screens/main/components/SettingsView/AccountSettings/AccountSettings.tsx @@ -44,7 +44,7 @@ export function AccountSettings() { ) : user ? ( <> - + {initials || "?"} diff --git a/apps/desktop/src/renderer/screens/main/components/TasksView/TasksView.tsx b/apps/desktop/src/renderer/screens/main/components/TasksView/TasksView.tsx index 5c1c006d8..c695e48a0 100644 --- a/apps/desktop/src/renderer/screens/main/components/TasksView/TasksView.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TasksView/TasksView.tsx @@ -33,12 +33,10 @@ import { HiUser, } from "react-icons/hi2"; import { - ActiveOrganizationProvider, CollectionsProvider, - OrganizationsProvider, - useActiveOrganization, useCollections, -} from "renderer/contexts"; +} from "renderer/contexts/CollectionsProvider"; +import { OrganizationsProvider } from "renderer/contexts/OrganizationsProvider"; import { OrganizationSwitcher } from "./components/OrganizationSwitcher"; interface TaskEditDialogProps { @@ -48,18 +46,16 @@ interface TaskEditDialogProps { } function TaskEditDialog({ task, open, onOpenChange }: TaskEditDialogProps) { + const collections = useCollections(); const [title, setTitle] = useState(task.title); const [description, setDescription] = useState(task.description || ""); const [priority, setPriority] = useState(task.priority); const [isSaving, setIsSaving] = useState(false); - const { tasks: tasksCollection } = useCollections(); const handleSave = async () => { setIsSaving(true); try { - // Use collection's update method - this triggers onUpdate handler - // which sends the mutation to the API - await tasksCollection.update(task.id, (draft) => { + await collections.tasks.update(task.id, (draft: SelectTask) => { draft.title = title; draft.description = description || null; draft.priority = priority as @@ -264,15 +260,12 @@ function TaskCard({ } function TasksList() { + const collections = useCollections(); const [editingTask, setEditingTask] = useState(null); - const { tasks: tasksCollection } = useCollections(); - const { activeOrganizationId } = useActiveOrganization(); - // Query all task objects from collection - // Include tasksCollection and activeOrganizationId in deps to force re-query when they change const { data: allTasks, isLoading } = useLiveQuery( - (q) => q.from({ tasks: tasksCollection }), - [tasksCollection, activeOrganizationId], + (q) => q.from({ tasks: collections.tasks }), + [collections], ); // Filter out deleted tasks in JavaScript @@ -354,11 +347,9 @@ function TasksViewContent() { export function TasksView() { return ( - - - - - + + + ); } diff --git a/apps/desktop/src/renderer/screens/main/components/TasksView/components/OrganizationSwitcher/OrganizationSwitcher.tsx b/apps/desktop/src/renderer/screens/main/components/TasksView/components/OrganizationSwitcher/OrganizationSwitcher.tsx index 8a5f1088c..21543ad0e 100644 --- a/apps/desktop/src/renderer/screens/main/components/TasksView/components/OrganizationSwitcher/OrganizationSwitcher.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TasksView/components/OrganizationSwitcher/OrganizationSwitcher.tsx @@ -5,20 +5,42 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@superset/ui/dropdown-menu"; +import { useLiveQuery } from "@tanstack/react-db"; import { HiCheck, HiChevronUpDown } from "react-icons/hi2"; -import { useActiveOrganization, useOrganizations } from "renderer/contexts"; +import { useCollections } from "renderer/contexts/CollectionsProvider"; +import { trpc } from "renderer/lib/trpc"; export function OrganizationSwitcher() { - const organizations = useOrganizations(); - const { activeOrganization, switchOrganization } = useActiveOrganization(); + const collections = useCollections(); + const { data: session } = trpc.auth.onSessionChange.useSubscription(); + const setActiveOrg = trpc.auth.setActiveOrganization.useMutation(); + + const activeOrganizationId = session?.session.activeOrganizationId; + + const { data: organizations } = useLiveQuery( + (q) => q.from({ organizations: collections.organizations }), + [collections], + ); + + const activeOrganization = organizations?.find( + (o) => o.id === activeOrganizationId, + ); + + if (!activeOrganization) { + return null; + } const initials = activeOrganization.name ?.split(" ") - .map((n) => n[0]) + .map((n: string) => n[0]) .join("") .toUpperCase() .slice(0, 2); + const switchOrganization = async (newOrgId: string) => { + await setActiveOrg.mutateAsync({ organizationId: newOrgId }); + }; + return ( @@ -27,7 +49,7 @@ export function OrganizationSwitcher() { className="flex items-center gap-2 w-full px-2 py-1.5 rounded-md hover:bg-muted transition-colors text-left" > - + {initials || "?"} @@ -39,14 +61,14 @@ export function OrganizationSwitcher() { - {organizations.map((organization) => ( + {organizations?.map((organization) => ( switchOrganization(organization.id)} className="gap-2" > - + {organization.name?.[0]?.toUpperCase() || "?"} diff --git a/apps/desktop/src/renderer/screens/sign-in/index.tsx b/apps/desktop/src/renderer/screens/sign-in/index.tsx index 032ebb07a..3fe2118f9 100644 --- a/apps/desktop/src/renderer/screens/sign-in/index.tsx +++ b/apps/desktop/src/renderer/screens/sign-in/index.tsx @@ -1,11 +1,10 @@ -import { COMPANY } from "@superset/shared/constants"; +import { type AuthProvider, COMPANY } from "@superset/shared/constants"; import { Button } from "@superset/ui/button"; import { useEffect } from "react"; import { FaGithub } from "react-icons/fa"; import { FcGoogle } from "react-icons/fc"; import { posthog } from "renderer/lib/posthog"; import { trpc } from "renderer/lib/trpc"; -import type { AuthProvider } from "shared/auth"; import { SupersetLogo } from "./components/SupersetLogo"; export function SignInScreen() { diff --git a/apps/desktop/src/shared/auth.ts b/apps/desktop/src/shared/auth.ts deleted file mode 100644 index 2f3348da3..000000000 --- a/apps/desktop/src/shared/auth.ts +++ /dev/null @@ -1,16 +0,0 @@ -export { AUTH_PROVIDERS, type AuthProvider } from "@superset/shared/constants"; - -/** - * Auth session - just tokens, user data fetched separately via tRPC - */ -export interface AuthSession { - accessToken: string; - accessTokenExpiresAt: number; - refreshToken: string; - refreshTokenExpiresAt: number; -} - -export interface SignInResult { - success: boolean; - error?: string; -} diff --git a/apps/desktop/trusted-dependencies-scripts.json b/apps/desktop/trusted-dependencies-scripts.json index 4909d16da..842071508 100644 --- a/apps/desktop/trusted-dependencies-scripts.json +++ b/apps/desktop/trusted-dependencies-scripts.json @@ -2,7 +2,6 @@ "esbuild", "electron", "electron-vite", - "@biomejs/biome", "@tailwindcss/oxide", "electron-winstaller" ] diff --git a/apps/marketing/package.json b/apps/marketing/package.json index 52fab968e..af17cd237 100644 --- a/apps/marketing/package.json +++ b/apps/marketing/package.json @@ -11,10 +11,10 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@clerk/nextjs": "^6.36.2", "@react-three/drei": "^10.7.6", "@react-three/fiber": "^9.4.0", "@sentry/nextjs": "^10.32.1", + "@superset/auth": "workspace:*", "@superset/shared": "workspace:*", "@superset/ui": "workspace:*", "@t3-oss/env-nextjs": "^0.13.8", diff --git a/apps/marketing/src/app/components/CTAButtons/CTAButtons.tsx b/apps/marketing/src/app/components/CTAButtons/CTAButtons.tsx index 53f41fa7d..04f964679 100644 --- a/apps/marketing/src/app/components/CTAButtons/CTAButtons.tsx +++ b/apps/marketing/src/app/components/CTAButtons/CTAButtons.tsx @@ -1,13 +1,14 @@ -import { auth } from "@clerk/nextjs/server"; +import { auth } from "@superset/auth/server"; import { DOWNLOAD_URL_MAC_ARM64 } from "@superset/shared/constants"; import { Download } from "lucide-react"; +import { headers } from "next/headers"; import { env } from "@/env"; export async function CTAButtons() { - const { userId } = await auth(); + const session = await auth.api.getSession({ headers: await headers() }); - if (userId) { + if (session) { return ( <> ) { return ( - - - -