feat: migrate auth stack and admin portal
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
parent
ff674d5bb5
commit
7946b8d017
46 changed files with 2564 additions and 178 deletions
|
|
@ -1,47 +1,118 @@
|
|||
"use client";
|
||||
"use client"
|
||||
|
||||
import React, { createContext, useContext, useEffect, useMemo, useState } from "react";
|
||||
import type { Doc } from "@/convex/_generated/dataModel";
|
||||
import { useMutation } from "convex/react";
|
||||
import { createContext, useContext, useEffect, useMemo, useState } from "react"
|
||||
import { customSessionClient } from "better-auth/client/plugins"
|
||||
import { createAuthClient } from "better-auth/react"
|
||||
import { useMutation } from "convex/react"
|
||||
|
||||
// Lazy import to avoid build errors before convex is generated
|
||||
// @ts-expect-error Convex generates runtime API without types until build
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||
import { isAdmin, isCustomer, isStaff } from "@/lib/authz"
|
||||
|
||||
export type DemoUser = { name: string; email: string; avatarUrl?: string } | null;
|
||||
export type AppSession = {
|
||||
user: {
|
||||
id: string
|
||||
name?: string | null
|
||||
email: string
|
||||
role: string
|
||||
tenantId: string | null
|
||||
avatarUrl: string | null
|
||||
}
|
||||
session: {
|
||||
id: string
|
||||
expiresAt: number
|
||||
}
|
||||
}
|
||||
|
||||
const authClient = createAuthClient({
|
||||
plugins: [customSessionClient<AppSession>()],
|
||||
fetchOptions: {
|
||||
credentials: "include",
|
||||
},
|
||||
})
|
||||
|
||||
type AuthContextValue = {
|
||||
demoUser: DemoUser;
|
||||
userId: string | null;
|
||||
setDemoUser: (u: DemoUser) => void;
|
||||
};
|
||||
session: AppSession | null
|
||||
isLoading: boolean
|
||||
convexUserId: string | null
|
||||
role: string | null
|
||||
isAdmin: boolean
|
||||
isStaff: boolean
|
||||
isCustomer: boolean
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue>({ demoUser: null, userId: null, setDemoUser: () => {} });
|
||||
const AuthContext = createContext<AuthContextValue>({
|
||||
session: null,
|
||||
isLoading: true,
|
||||
convexUserId: null,
|
||||
role: null,
|
||||
isAdmin: false,
|
||||
isStaff: false,
|
||||
isCustomer: false,
|
||||
})
|
||||
|
||||
export function useAuth() {
|
||||
return useContext(AuthContext);
|
||||
return useContext(AuthContext)
|
||||
}
|
||||
|
||||
export function AuthProvider({ demoUser, tenantId, children }: { demoUser: DemoUser; tenantId: string; children: React.ReactNode }) {
|
||||
const [localDemoUser, setLocalDemoUser] = useState<DemoUser>(demoUser);
|
||||
const [userId, setUserId] = useState<string | null>(null);
|
||||
const ensureUser = useMutation(api.users.ensureUser);
|
||||
export const { signIn, signOut, useSession } = authClient
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const { data: session, isPending } = useSession()
|
||||
const ensureUser = useMutation(api.users.ensureUser)
|
||||
const [convexUserId, setConvexUserId] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
async function run() {
|
||||
if (!process.env.NEXT_PUBLIC_CONVEX_URL) return; // allow dev without backend
|
||||
if (!localDemoUser) return;
|
||||
try {
|
||||
const user = (await ensureUser({ tenantId, name: localDemoUser.name, email: localDemoUser.email, avatarUrl: localDemoUser.avatarUrl })) as Doc<"users"> | null;
|
||||
setUserId(user?._id ?? null);
|
||||
} catch (e) {
|
||||
console.error("Failed to ensure user:", e);
|
||||
}
|
||||
if (!session?.user) {
|
||||
setConvexUserId(null)
|
||||
}
|
||||
run();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [localDemoUser?.email, tenantId]);
|
||||
}, [session?.user])
|
||||
|
||||
const value = useMemo(() => ({ demoUser: localDemoUser, setDemoUser: setLocalDemoUser, userId }), [localDemoUser, userId]);
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
useEffect(() => {
|
||||
if (!session?.user || !process.env.NEXT_PUBLIC_CONVEX_URL || convexUserId) return
|
||||
|
||||
const controller = new AbortController()
|
||||
|
||||
;(async () => {
|
||||
try {
|
||||
const ensured = await ensureUser({
|
||||
tenantId: session.user.tenantId ?? DEFAULT_TENANT_ID,
|
||||
name: session.user.name ?? session.user.email,
|
||||
email: session.user.email,
|
||||
avatarUrl: session.user.avatarUrl ?? undefined,
|
||||
role: session.user.role.toUpperCase(),
|
||||
})
|
||||
if (!controller.signal.aborted) {
|
||||
setConvexUserId(ensured?._id ?? null)
|
||||
}
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted) {
|
||||
console.error("Failed to sync user with Convex", error)
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
controller.abort()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ensureUser, session?.user?.email, convexUserId])
|
||||
|
||||
const normalizedRole = session?.user?.role ? session.user.role.toLowerCase() : null
|
||||
|
||||
const value = useMemo<AuthContextValue>(
|
||||
() => ({
|
||||
session: session ?? null,
|
||||
isLoading: isPending,
|
||||
convexUserId,
|
||||
role: normalizedRole,
|
||||
isAdmin: isAdmin(normalizedRole),
|
||||
isStaff: isStaff(normalizedRole),
|
||||
isCustomer: isCustomer(normalizedRole),
|
||||
}),
|
||||
[session, isPending, convexUserId, normalizedRole]
|
||||
)
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue