refactor: enhance onboarding logic and update default redirect path
This commit is contained in:
@@ -20,17 +20,11 @@ import {
|
|||||||
useGetMeApiV1UsersMeGet,
|
useGetMeApiV1UsersMeGet,
|
||||||
useUpdateMeApiV1UsersMePatch,
|
useUpdateMeApiV1UsersMePatch,
|
||||||
} from "@/api/generated/users/users";
|
} from "@/api/generated/users/users";
|
||||||
import type { UserRead } from "@/api/generated/model";
|
|
||||||
import { DashboardShell } from "@/components/templates/DashboardShell";
|
import { DashboardShell } from "@/components/templates/DashboardShell";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import SearchableSelect from "@/components/ui/searchable-select";
|
import SearchableSelect from "@/components/ui/searchable-select";
|
||||||
|
import { isOnboardingComplete } from "@/lib/onboarding";
|
||||||
const isCompleteProfile = (profile: UserRead | null | undefined) => {
|
|
||||||
if (!profile) return false;
|
|
||||||
const resolvedName = profile.preferred_name?.trim() || profile.name?.trim();
|
|
||||||
return Boolean(resolvedName) && Boolean(profile.timezone?.trim());
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function OnboardingPage() {
|
export default function OnboardingPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -113,7 +107,7 @@ export default function OnboardingPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (profile && isCompleteProfile(profile)) {
|
if (profile && isOnboardingComplete(profile)) {
|
||||||
router.replace("/dashboard");
|
router.replace("/dashboard");
|
||||||
}
|
}
|
||||||
}, [profile, router]);
|
}, [profile, router]);
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ describe("resolveSignInRedirectUrl", () => {
|
|||||||
expect(resolveSignInRedirectUrl(null)).toBe("/boards");
|
expect(resolveSignInRedirectUrl(null)).toBe("/boards");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("defaults to /dashboard when no env fallback is set", () => {
|
it("defaults to /onboarding when no env fallback is set", () => {
|
||||||
expect(resolveSignInRedirectUrl(null)).toBe("/dashboard");
|
expect(resolveSignInRedirectUrl(null)).toBe("/onboarding");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows safe relative paths", () => {
|
it("allows safe relative paths", () => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const DEFAULT_SIGN_IN_REDIRECT = "/dashboard";
|
const DEFAULT_SIGN_IN_REDIRECT = "/onboarding";
|
||||||
|
|
||||||
function isSafeRelativePath(value: string): boolean {
|
function isSafeRelativePath(value: string): boolean {
|
||||||
return value.startsWith("/") && !value.startsWith("//");
|
return value.startsWith("/") && !value.startsWith("//");
|
||||||
|
|||||||
@@ -2,17 +2,48 @@
|
|||||||
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
|
|
||||||
import { SignedIn, useUser } from "@/auth/clerk";
|
import { SignedIn, useAuth, useUser } from "@/auth/clerk";
|
||||||
|
|
||||||
|
import { ApiError } from "@/api/mutator";
|
||||||
|
import {
|
||||||
|
type getMeApiV1UsersMeGetResponse,
|
||||||
|
useGetMeApiV1UsersMeGet,
|
||||||
|
} from "@/api/generated/users/users";
|
||||||
import { BrandMark } from "@/components/atoms/BrandMark";
|
import { BrandMark } from "@/components/atoms/BrandMark";
|
||||||
import { OrgSwitcher } from "@/components/organisms/OrgSwitcher";
|
import { OrgSwitcher } from "@/components/organisms/OrgSwitcher";
|
||||||
import { UserMenu } from "@/components/organisms/UserMenu";
|
import { UserMenu } from "@/components/organisms/UserMenu";
|
||||||
|
import { isOnboardingComplete } from "@/lib/onboarding";
|
||||||
|
|
||||||
export function DashboardShell({ children }: { children: ReactNode }) {
|
export function DashboardShell({ children }: { children: ReactNode }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const { isSignedIn } = useAuth();
|
||||||
const { user } = useUser();
|
const { user } = useUser();
|
||||||
const displayName =
|
const displayName =
|
||||||
user?.fullName ?? user?.firstName ?? user?.username ?? "Operator";
|
user?.fullName ?? user?.firstName ?? user?.username ?? "Operator";
|
||||||
|
const isOnboardingPath = pathname === "/onboarding";
|
||||||
|
|
||||||
|
const meQuery = useGetMeApiV1UsersMeGet<
|
||||||
|
getMeApiV1UsersMeGetResponse,
|
||||||
|
ApiError
|
||||||
|
>({
|
||||||
|
query: {
|
||||||
|
enabled: Boolean(isSignedIn) && !isOnboardingPath,
|
||||||
|
retry: false,
|
||||||
|
refetchOnMount: "always",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const profile = meQuery.data?.status === 200 ? meQuery.data.data : null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isSignedIn || isOnboardingPath) return;
|
||||||
|
if (!profile) return;
|
||||||
|
if (!isOnboardingComplete(profile)) {
|
||||||
|
router.replace("/onboarding");
|
||||||
|
}
|
||||||
|
}, [isOnboardingPath, isSignedIn, profile, router]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
|
|||||||
47
frontend/src/lib/onboarding.test.ts
Normal file
47
frontend/src/lib/onboarding.test.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { isOnboardingComplete } from "@/lib/onboarding";
|
||||||
|
|
||||||
|
describe("isOnboardingComplete", () => {
|
||||||
|
it("returns false when profile is missing", () => {
|
||||||
|
expect(isOnboardingComplete(null)).toBe(false);
|
||||||
|
expect(isOnboardingComplete(undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when timezone is missing", () => {
|
||||||
|
expect(
|
||||||
|
isOnboardingComplete({
|
||||||
|
preferred_name: "Asha",
|
||||||
|
timezone: "",
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when both name fields are missing", () => {
|
||||||
|
expect(
|
||||||
|
isOnboardingComplete({
|
||||||
|
name: " ",
|
||||||
|
preferred_name: " ",
|
||||||
|
timezone: "America/New_York",
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts preferred_name + timezone", () => {
|
||||||
|
expect(
|
||||||
|
isOnboardingComplete({
|
||||||
|
preferred_name: "Asha",
|
||||||
|
timezone: "America/New_York",
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts fallback name + timezone", () => {
|
||||||
|
expect(
|
||||||
|
isOnboardingComplete({
|
||||||
|
name: "Asha",
|
||||||
|
timezone: "America/New_York",
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
13
frontend/src/lib/onboarding.ts
Normal file
13
frontend/src/lib/onboarding.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
type OnboardingProfileLike = {
|
||||||
|
name?: string | null;
|
||||||
|
preferred_name?: string | null;
|
||||||
|
timezone?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isOnboardingComplete(
|
||||||
|
profile: OnboardingProfileLike | null | undefined,
|
||||||
|
): boolean {
|
||||||
|
if (!profile) return false;
|
||||||
|
const resolvedName = profile.preferred_name?.trim() || profile.name?.trim();
|
||||||
|
return Boolean(resolvedName) && Boolean(profile.timezone?.trim());
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user