feat(dashboard): add onboarding gate forcing company setup before app use
After login/activation, show a full-screen loader while company settings resolve, then force the user onto /settings/company and block navigation to any other route until the company info is filled in. The gate releases once the company record has a name; fails open on API error so a transient failure can never lock the user out. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
fdce301495
commit
97ad275588
@ -6,6 +6,7 @@ import { DashboardLayout } from "@/base/components/layout/dashboard"
|
|||||||
import { useAuth } from "@/shared/hooks/use-auth"
|
import { useAuth } from "@/shared/hooks/use-auth"
|
||||||
import { navGroups } from "@/config/navGroups"
|
import { navGroups } from "@/config/navGroups"
|
||||||
import { getAuthCookies } from "@/modules/auth/auth.actions"
|
import { getAuthCookies } from "@/modules/auth/auth.actions"
|
||||||
|
import { OnboardingGate } from "@/shared/components/onboarding-gate"
|
||||||
import { redirect } from "next/navigation"
|
import { redirect } from "next/navigation"
|
||||||
|
|
||||||
|
|
||||||
@ -38,7 +39,7 @@ export default async function AuthenticatedLayout({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardLayout navGroups={navGroups} logo={<Logo />} user={userInfo}>
|
<DashboardLayout navGroups={navGroups} logo={<Logo />} user={userInfo}>
|
||||||
{children}
|
<OnboardingGate>{children}</OnboardingGate>
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { AlertTriangle, Save } from "lucide-react"
|
|||||||
import { useEffect } from "react"
|
import { useEffect } from "react"
|
||||||
import { useForm } from "react-hook-form"
|
import { useForm } from "react-hook-form"
|
||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
import { useQuery } from "@tanstack/react-query"
|
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
@ -122,6 +122,7 @@ function mapFormToPayload(values: SettingsFormValues) {
|
|||||||
|
|
||||||
export function SettingsForm() {
|
export function SettingsForm() {
|
||||||
const api = useAuthApi()
|
const api = useAuthApi()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
const form = useForm<SettingsFormValues>({
|
const form = useForm<SettingsFormValues>({
|
||||||
resolver: zodResolver(settingsFormSchema) as any,
|
resolver: zodResolver(settingsFormSchema) as any,
|
||||||
@ -154,6 +155,9 @@ export function SettingsForm() {
|
|||||||
mutationFn: (values: SettingsFormValues) => api.settings.update(mapFormToPayload(values)),
|
mutationFn: (values: SettingsFormValues) => api.settings.update(mapFormToPayload(values)),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("Settings saved successfully")
|
toast.success("Settings saved successfully")
|
||||||
|
// Refresh the settings query so the onboarding gate releases once
|
||||||
|
// the company info has been filled in.
|
||||||
|
queryClient.invalidateQueries({ queryKey: [SETTINGS_ROUTES.INDEX] })
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
toast.error("Failed to save settings")
|
toast.error("Failed to save settings")
|
||||||
|
|||||||
72
apps/dashboard/shared/components/onboarding-gate.tsx
Normal file
72
apps/dashboard/shared/components/onboarding-gate.tsx
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useMemo } from "react"
|
||||||
|
import { usePathname, useRouter } from "next/navigation"
|
||||||
|
import { useQuery } from "@tanstack/react-query"
|
||||||
|
import { Loader2 } from "lucide-react"
|
||||||
|
|
||||||
|
import { useAuthApi } from "@/shared/useApi"
|
||||||
|
import { SETTINGS_ROUTES } from "@garage/api"
|
||||||
|
|
||||||
|
const COMPANY_SETUP_PATH = "/settings/company"
|
||||||
|
|
||||||
|
function FullScreenLoader({ label }: { label: string }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-background fixed inset-0 z-50 flex flex-col items-center justify-center gap-3">
|
||||||
|
<Loader2 className="text-primary size-8 animate-spin" />
|
||||||
|
<p className="text-muted-foreground text-sm">{label}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Onboarding gate.
|
||||||
|
*
|
||||||
|
* After login/activation the workspace's company information must be filled in
|
||||||
|
* before the rest of the app is usable. While the settings record loads we show
|
||||||
|
* a full-screen loader; if the company isn't set up yet we force the user onto
|
||||||
|
* the company-settings page and block navigation to any other route until it is.
|
||||||
|
*/
|
||||||
|
export function OnboardingGate({ children }: { children: React.ReactNode }) {
|
||||||
|
const api = useAuthApi()
|
||||||
|
const router = useRouter()
|
||||||
|
const pathname = usePathname()
|
||||||
|
|
||||||
|
const { data, isLoading, isError } = useQuery({
|
||||||
|
queryKey: [SETTINGS_ROUTES.INDEX],
|
||||||
|
queryFn: () => api.settings.fetch(),
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
retry: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
const isCompanyFilled = useMemo(() => {
|
||||||
|
const raw = (data as any)?.data
|
||||||
|
const record = Array.isArray(raw) ? raw[0] : raw
|
||||||
|
return Boolean(record?.id) && Boolean(String(record?.name ?? "").trim())
|
||||||
|
}, [data])
|
||||||
|
|
||||||
|
const onCompanyPage = pathname?.startsWith(COMPANY_SETUP_PATH) ?? false
|
||||||
|
|
||||||
|
// Onboarding is required once we know (loaded, no error) the company isn't set up.
|
||||||
|
// Fail open on error so a transient API failure can never lock the user out.
|
||||||
|
const mustOnboard = !isLoading && !isError && !isCompanyFilled
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mustOnboard && !onCompanyPage) {
|
||||||
|
router.replace(COMPANY_SETUP_PATH)
|
||||||
|
}
|
||||||
|
}, [mustOnboard, onCompanyPage, router])
|
||||||
|
|
||||||
|
// Initial load — determine onboarding status before showing anything.
|
||||||
|
if (isLoading) {
|
||||||
|
return <FullScreenLoader label="Loading your workspace…" />
|
||||||
|
}
|
||||||
|
|
||||||
|
// Needs onboarding and not yet on the company page → show loader while redirecting,
|
||||||
|
// so other pages never flash or become usable.
|
||||||
|
if (mustOnboard && !onCompanyPage) {
|
||||||
|
return <FullScreenLoader label="Let's set up your company first…" />
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user