feat/onboarding (#337)

* feat(onboarding): add cookie helper, types and mock data

* feat(onboarding): add stepper, contextual preview and shell

* feat(onboarding): add sso-gate step

* feat(onboarding): add account-info step with real signup

* feat(onboarding): add security step

* feat(onboarding): add preferences step

* fix(onboarding): validate avatar upload size/type and handle FileReader errors

* feat(onboarding): add org-create step

* feat(onboarding): add invite-members step

* feat(onboarding): add notifier step reusing real provider list

* feat(onboarding): add storage step reusing real provider list

* feat(onboarding): add defaults step

* feat(onboarding): add agent-create step

* feat(onboarding): add agent-waiting step with simulated ping

* feat(onboarding): add project-create step

* feat(onboarding): add db-settings step with per-db repeat

* feat(onboarding): add finish step with confetti

* feat(onboarding): wire full step graph

* feat(onboarding): add /welcome route

* feat(onboarding): gate landing, auth and dashboard routes behind onboarding cookie

* fix(onboarding): call next() in finish step so onFlowComplete fires and cookie is written

* docs: add onboarding channel config + style refactor spec

* docs: add conditional step skip when no agents to spec

* docs(onboarding): add implementation plan for channel config flow and style refactor

* feat(onboarding): extend OnboardingChannel with name and config fields

* feat(onboarding): add two-phase config flow and org-combobox style to notifier step

* fix(onboarding): prevent duplicate notifier providers and guard Continue button

* feat(onboarding): add two-phase config flow and org-combobox style to storage step

* style(onboarding): restyle sso-gate provider buttons to org-combobox card style

* style(onboarding): restyle security method choice to org-combobox card style

* style(onboarding): restyle theme toggles in preferences step to org-combobox card style

* style(onboarding): restyle DB toggle buttons in project-create step to org-combobox card style

* feat(onboarding): skip agent-waiting/project-create/db-settings when no agents created

* feat(onboarding): update type system — meta, firstName/lastName, org.id, databases

* feat(onboarding): add settings.onboarding column + markOnboardingDoneAction

* feat(onboarding): add resolveOnboardingState server function

* fix(onboarding): remove unused imports from onboarding-state.ts

* style(onboarding): add branding to shell, fix progress bar full width

* feat(onboarding): replace preview panel with stepper checklist

* feat(onboarding): add step-login replacing sso-gate

* fix(onboarding): step-login — conform to global mutation pattern, handle SSO errors

* feat(onboarding): step-account-info — firstName/lastName, conditional password, findOrCreate

* feat(onboarding): avatar API route (next/og) + AvatarPicker in preferences step

* feat(onboarding): step-security reads passkeyEnabled from flowData.meta

* feat(onboarding): step-org-create calls real createOrganizationAction

* fix(onboarding): step-org-create — simplify error handling, throw on empty name

* feat(onboarding): notifier + storage steps call real actions + findOrCreate on back

* feat(onboarding): step-agent-create calls real createAgentAction + findOrCreate

* feat(onboarding): step-agent-waiting polls real agent lastContact via useQuery

* feat(onboarding): add step-agent-key with edgeKey generation via server action

* feat(onboarding): step-project-create uses real action + real DBs from flowData

* feat(onboarding): wire steps — add login + agent-key, remove sso-gate

* feat(onboarding): page.tsx uses resolveOnboardingState — server-driven resume

* chore(onboarding): delete mock/cookie/preview/sso-gate files — replaced by real implementation

* feat(onboarding): add emailPasswordEnabled to meta, remove logoDataUrl from org

* feat(onboarding): DB-driven resume — query notifiers/storages, check agent lastContact

* feat(onboarding): agent-waiting step is now skippable

* feat(onboarding): login — auto-advance if session, callbackURL on SSO, hide email/pw when disabled, empty state

* fix(onboarding): step-login — add next to useEffect deps, default emailPasswordEnabled to false

* fix(onboarding): verify session after passkey registration, fallback to signIn.passkey()

* feat(onboarding): theme applied dynamically via setTheme, avatar saved to user.image

* feat(onboarding): remove org logo upload — orgs have no logo

* feat(onboarding): show Next button in shell when navigating backwards

* docs: add onboarding refactor design spec

* docs: add onboarding refactor implementation plan

* refactor(onboarding): extract useCreateAgent and useDeleteAgent hooks

* refactor(onboarding): extract useCreateOrg and useCreateProject hooks

* fix(onboarding): trim project name, standardize error narrowing in org/project hooks

* fix(onboarding): use narrowed createData.value in use-create-org

* refactor(onboarding): extract useUpdateAccount hook

* refactor(onboarding): extract useAddNotifier and useRemoveNotifier hooks

* refactor(onboarding): extract useAddStorage and useRemoveStorage hooks

* refactor(onboarding): extract useAgentStatus and useGenerateEdgeKey hooks

* refactor(onboarding): extract useMarkOnboardingDone hook, remove .gitkeep files

* docs: add onboarding theme persistence & progress bar fix spec

* docs: add onboarding theme & progress bar implementation plan

* fix(onboarding): persist theme to db on selection in StepPreferences

* fix(onboarding): recalculate progress bar from full STEP_ORDER position

* fix(theme): sync user theme from DB on all routes, not only dashboard

* fix(theme): use current theme as fallback in preferences, prevent session override of localStorage

* fix(onboarding): correct resume steps and skip agent-waiting flash when agent is connected

* fix(onboarding): properly resolve onboarding state, remove redirect trap in step-defaults, block waiting flash

* fix(onboarding): resume at db-settings instead of finish if no database exists for the project

* fix(onboarding): redirect to agent-create if project exists but no agent is found

* fix(onboarding): allow going from agent-create to project-create if a project already exists

* fix(onboarding): skip intermediate agent steps when going back from project-create to agent-create

* fix(onboarding): skip default step when going back from agent-create if no notifiers and storages exist

* feat(onboarding): allow user to choose between passkey and password during registration if both are enabled

* feat(onboarding): replace tabs with single form and multiple buttons for auth choice

* style(onboarding): improve empty auth state UI with lucide icon and better layout

* fix(onboarding): completely hide back button on the first step instead of disabling it

* fix(onboarding): skip default step if no notifiers and storages exist when advancing from storage step

* fix(onboarding): correctly show select placeholder when default value does not exist in the list

* refactor(onboarding): remove duplicate generate-edge-key action and use core utility directly

* feat(onboarding): compute edgeKey directly on the server side to avoid lazy loading

* fix(onboarding): skip db-settings step if no database is found on the agent

* fix(onboarding): do not skip db settings if agent is connected but no databases found

* refactor(onboarding): skip db settings entirely if no database is selected in project create

* feat(onboarding): auto-save database selections on project create instantly

* style(onboarding): replace button opacity flash with specific spinner animation on db select

* feat(onboarding): extend OnboardingDbSettings with retention, schedule, and policy fields

* feat(onboarding): add applyOnboardingDbSettingsAction server action

* feat(onboarding): add BackupScheduleSelector component

* feat(onboarding): rewrite step-db-settings with three-level phase navigation and full policy persistence

* fix(onboarding): wire applyMutation.mutateAsync everywhere and stabilize list keys

* docs(sdd): append hotfix summary for applyMutation and list keys

* fix(onboarding): address final review findings - atomicity, scheduling guard, apply-all recovery, client validation

* add: onboarding

* fix(migration): merge

* fix(migration): apply for onboarding

* fix(settings): make avatar selector, dicebear picker and tab bar responsive

* chore: checkpoint before features reorganization

* Task 2: Add migration script for features directory reorganization

* refactor(agents): reorganize into actions/components/schemas/hooks

* refactor(auth): reorganize into actions/components/schemas

* refactor(channel): reorganize into components/schemas, nest notifications+storages under components

* refactor(database): reorganize into actions/components/schemas

* refactor(layout): move all components to components/

* refactor(logs): reorganize into components/types

* refactor(migration): reorganize into actions/components

* refactor(notifications): reorganize into components/types/utils

* refactor(organizations): reorganize into actions/components/schemas/hooks/utils

* refactor(profile): reorganize into actions/components/schemas

* refactor(projects): reorganize into actions/components/schemas

* refactor(settings): reorganize into actions/components/schemas/hooks

* refactor(statistics): reorganize into components/utils

* refactor(storages): reorganize into types/utils

* refactor(theme): move all components to components/

* refactor(updates): reorganize into components/hooks

* refactor(upload): move action to actions/

* refactor(users): reorganize into actions/components/schemas

* fix: update broken imports after features reorganization (double-quote relative imports, channel subdir absolute paths)

* fix: .gitignore

* fix: onboarding

* fix: removed dead files

* feat: backfill onboarding flag for instances with existing data

* fix: persist onboarding=true on boot when SKIP_ONBOARDING is set

* feat: remove avatarMode/dicebearStyle from DB schema and types

* feat: simplify resolveAvatarUrl — custom image first, gravatar fallback

* feat: allow avatar upload regardless of mode

* feat: remove avatarMode from layout and profile prop chains

* feat: remove avatar mode admin tab and supporting files

* feat: remove avatar mode selector from onboarding defaults step

* fix: remove preferences onboarding step and fix routing

* fix: remove stale preferences entry from onboarding checklist

* feat: add avatar reset button and explanatory tooltip on profile page

* fix: move avatar tooltip to top-right badge overlay on avatar

* fix: use small destructive button for avatar removal

* fix

* Update step-defaults.tsx

* fix: default dev password for keycloak refer to env

* fix

* fix(migration): refactoring

* fix: api agent helpers.ts

* Update .env.example

---------

Co-authored-by: Théo LAGACHE <theo.lagache@soluce-technologies.com>
Co-authored-by: charles-gauthereau <charles.gauthereau@soluce-technologies.com>
This commit is contained in:
Théo LAGACHE
2026-06-27 17:08:38 +02:00
committed by GitHub
co-authored by Théo LAGACHE charles-gauthereau
parent 40d3228db6
commit 40247efddd
451 changed files with 15904 additions and 5054 deletions
@@ -0,0 +1,45 @@
import { ReactNode } from "react";
import {
Sidebar,
SidebarContent,
SidebarHeader,
SidebarMenu,
SidebarMenuItem
} from "@/components/ui/sidebar";
import {SidebarLogo} from "@/features/layout/components/logo-sidebar";
import {SidebarMenuCustomMain} from "@/features/layout/components/menu-sidebar-main";
import {SideBarFooterCredit} from "@/features/layout/components/side-bar-footer-credit";
import {OrganizationCombobox} from "@/features/organizations/components/organization-combobox";
import {env} from "@/env.mjs";
import {LoggedInButton} from "@/features/layout/components/logged-in-button.server";
export function AppSidebar({ updateNotification }: { updateNotification?: ReactNode } = {}) {
const projectName = env.PROJECT_NAME;
return (
<Sidebar collapsible="icon">
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarLogo projectName={projectName ?? "Portabase"}/>
</SidebarMenuItem>
<SidebarMenuItem>
<OrganizationCombobox/>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<SidebarMenuCustomMain/>
</SidebarContent>
{updateNotification}
<SidebarMenu className="mb-2">
<SidebarMenuItem className="p-2">
<LoggedInButton/>
</SidebarMenuItem>
</SidebarMenu>
<SideBarFooterCredit/>
</Sidebar>
);
}
@@ -0,0 +1,17 @@
import {cn} from "@/lib/utils";
import * as React from "react";
export function CardAuth({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-transparent border-none py-3",
"md:bg-card text-card-foreground flex flex-col gap-6 rounded-xl md:border-solid md:border md:py-6 md:shadow-sm",
className
)}
{...props}
/>
)
}
@@ -0,0 +1,54 @@
'use client';
import { useEffect, useState } from 'react';
import { Timer } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Badge } from '@/components/ui/badge';
function getSecondsUntilNextHour(): number {
const now = new Date();
const s = 3600 - (now.getMinutes() * 60 + now.getSeconds());
return s === 3600 ? 0 : s;
}
function formatTime(seconds: number): string {
const s = Math.max(0, seconds);
const m = Math.floor(s / 60);
const sec = s % 60;
return `${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
}
export function DemoResetBanner() {
const [secondsLeft, setSecondsLeft] = useState<number | null>(null);
useEffect(() => {
const tick = () => setSecondsLeft(getSecondsUntilNextHour());
const timeout = setTimeout(tick, 0);
const interval = setInterval(tick, 1000);
return () => {
clearTimeout(timeout);
clearInterval(interval);
};
}, []);
if (secondsLeft === null) return null;
const isResetting = secondsLeft === 0;
const isWarning = secondsLeft > 0 && secondsLeft <= 300;
return (
<Badge
variant="outline"
className={cn(
'hidden md:inline-flex h-7',
isResetting && 'animate-pulse border-red-200 bg-red-50 text-red-700 dark:border-red-800 dark:bg-red-950/30 dark:text-red-400',
isWarning && 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-400',
)}
>
<Timer aria-hidden="true" />
{isResetting ? 'Resetting…' : `Demo resets in ${formatTime(secondsLeft)}`}
</Badge>
);
}
+31
View File
@@ -0,0 +1,31 @@
import { ReactNode } from "react";
import {notFound} from "next/navigation";
import {SidebarTrigger} from "@/components/ui/sidebar";
import {currentUser} from "@/lib/auth/current-user";
import {BreadCrumbsWrapper} from "@/components/common/bread-crumbs";
import GitHubStarsButtonCustom from "@/components/common/github-button";
import {LoggedInButton} from "@/features/layout/components/logged-in-button.server";
import { env } from "@/env.mjs";
import { DemoResetBanner } from "@/features/layout/components/demo-reset-banner";
export const Header = async ({ actions }: { actions?: ReactNode } = {}) => {
const user = await currentUser();
if (!user) {
return notFound();
}
const demoEnabled = env.DEMO_ENABLED;
return (
<header className="flex h-16 shrink-0 items-center justify-between border-b px-4">
<div className="flex items-center justify-between">
<SidebarTrigger className="-ml-1"/>
<BreadCrumbsWrapper/>
</div>
<div className="flex items-center gap-2">
{demoEnabled && <DemoResetBanner />}
<GitHubStarsButtonCustom/>
{actions}
<LoggedInButton/>
</div>
</header>
);
};
@@ -0,0 +1,30 @@
import { currentUser } from "@/lib/auth/current-user";
import { getAccounts, getSession, getSessions } from "@/lib/auth/auth";
import { LoggedInButtonClient } from "./logged-in-button";
import { SUPPORTED_PROVIDERS } from "@/lib/auth/config";
import { env } from "@/env.mjs";
import { resolveAvatarUrl } from "@/utils/resolve-avatar-url";
export const LoggedInButton = async () => {
const [user, sessions, currentSession, accounts] = await Promise.all([
currentUser(),
getSessions(),
getSession(),
getAccounts(),
]);
if (!user) return null;
return (
<LoggedInButtonClient
user={user}
sessions={sessions}
// @ts-ignore
currentSession={currentSession.session}
accounts={accounts}
providers={SUPPORTED_PROVIDERS.filter((p) => p.isActive)}
apiEnabled={env.API_ENABLED}
avatarUrl={resolveAvatarUrl(user)}
/>
);
};
@@ -0,0 +1,52 @@
"use client";
import { ChevronsUpDown } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { SidebarMenuButton } from "@/components/ui/sidebar";
import { LoggedInDropdown } from "./logged-in-dropdown";
import { Account, Session } from "better-auth";
import { AuthProviderConfig } from "@/lib/auth/config";
import { User } from "@/db/schema/02_user";
type LoggedInButtonClientProps = {
user: User;
sessions: Session[];
currentSession: Session;
accounts: Account[];
providers: AuthProviderConfig[];
apiEnabled: boolean;
avatarUrl?: string;
};
export const LoggedInButtonClient = ({ user, sessions, currentSession, accounts, providers, apiEnabled, avatarUrl }: LoggedInButtonClientProps) => {
return (
<LoggedInDropdown
user={user}
// @ts-ignore
sessions={sessions}
// @ts-ignore
currentSession={currentSession}
// @ts-ignore
accounts={accounts}
providers={providers}
apiEnabled={apiEnabled}
avatarUrl={avatarUrl}
>
<SidebarMenuButton type="button" className="h-auto justify-between py-2" data-testid="profile-dropdown">
<div className="flex items-center gap-2">
<Avatar key={avatarUrl ?? "fallback"} className="size-6">
<AvatarFallback>{(user.name?.[0] ?? user.email?.[0] ?? "?").toUpperCase()}</AvatarFallback>
{avatarUrl && <AvatarImage src={avatarUrl} />}
</Avatar>
<div className="flex flex-col items-start">
<span className="text-sm font-medium first-letter:capitalize max-w-[170px] truncate">{user.name}</span>
<span className="text-xs text-muted-foreground max-w-[170px] truncate" title={user.email}>
{user.email}
</span>
</div>
</div>
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
</SidebarMenuButton>
</LoggedInDropdown>
);
};
@@ -0,0 +1,82 @@
"use client";
import { PropsWithChildren, ReactNode, useState } from "react";
import { useRouter } from "next/navigation";
import { LogOut, User } from "lucide-react";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { signOut } from "@/lib/auth/auth-client";
import { ProfileModal } from "@/features/layout/components/profile-modal";
import { Account, Session, User as UserType } from "@/db/schema/02_user";
import { AuthProviderConfig } from "@/lib/auth/config";
export type LoggedInDropdownProps = PropsWithChildren<{
user: UserType;
sessions: Session[];
currentSession: Session;
accounts: Account[];
children: ReactNode;
providers: AuthProviderConfig[];
apiEnabled: boolean;
avatarUrl?: string;
}>;
export const LoggedInDropdown = ({ user, sessions, currentSession, accounts, children, providers, apiEnabled, avatarUrl }: LoggedInDropdownProps) => {
const router = useRouter();
const [isModalOpen, setIsModalOpen] = useState(false);
return (
<>
<ProfileModal
user={user}
sessions={sessions}
currentSession={currentSession}
accounts={accounts}
open={isModalOpen}
onOpenChange={setIsModalOpen}
providers={providers}
apiEnabled={apiEnabled}
avatarUrl={avatarUrl}
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent
className="w-[var(--radix-popper-anchor-width)] rounded-xl border-2 border-border bg-popover shadow-none p-1"
align="start"
side="top"
sideOffset={8}
>
<DropdownMenuItem
onClick={() => setIsModalOpen(!isModalOpen)}
className="group gap-2 p-1 cursor-pointer rounded-lg mb-1 transition-colors focus:bg-accent hover:bg-accent/50 border border-transparent"
>
<div className="flex size-9 items-center justify-center rounded-md border border-border bg-muted/50 shadow-sm transition-all group-hover:shadow-md group-hover:bg-background">
<User size={18} className="text-muted-foreground group-hover:text-foreground transition-colors" />
</div>
<div className="flex flex-col">
<span className="text-sm font-medium leading-none">Account Settings</span>
</div>
</DropdownMenuItem>
<DropdownMenuItem
className="group gap-2 p-1 cursor-pointer rounded-lg transition-colors focus:bg-red-50 dark:focus:bg-red-950/20 border border-transparent text-red-600 focus:text-red-600"
onClick={async () => {
await signOut({
fetchOptions: {
onSuccess: () => {
router.push("/login");
},
},
});
}}
>
<div className="flex size-9 items-center justify-center rounded-md border border-red-100 bg-red-50/50 dark:border-red-900/30 dark:bg-red-950/20 shadow-sm transition-all group-hover:shadow-md">
<LogOut size={18} className="text-red-500" />
</div>
<div className="flex flex-col">
<span className="text-sm font-medium leading-none">Logout</span>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
);
};
@@ -0,0 +1,68 @@
"use client";
import { useSidebar } from "@/components/ui/sidebar";
import Link from "next/link";
import { useTheme } from "next-themes";
import Image from "next/image";
import { useEffect, useState } from "react";
import {Skeleton} from "@/components/ui/skeleton";
export const SidebarLogo = ({ projectName }: { projectName: string }) => {
const { state, isMobile } = useSidebar();
const { resolvedTheme } = useTheme();
const [mounted, setMounted] = useState(false);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) return(
<div className="m-4 w-[190px] h-[45px]">
<Skeleton className="w-full h-full bg-transparent" />
</div>
);
const imageTheme =
resolvedTheme === "dark" ? "/images/logo-dark.png" : "/images/logo-light.png";
const handleLoad = () => setLoaded(true);
const style = {
transition: "opacity 0.3s ease-in-out",
opacity: loaded ? 1 : 0,
};
return (
<div className="ml-1 flex items-center justify-center">
<Link href="/dashboard/home">
{state === "collapsed" && !isMobile ? (
<Image
src="/images/logo.png"
alt={`Logo ${projectName}`}
width={40}
height={40}
loading="eager"
priority
style={style}
onLoad={handleLoad}
/>
) : (
<div className="m-4 w-[190px] h-[45px] ">
<Image
src={imageTheme}
alt={`Logo ${projectName}`}
width={190}
height={90}
loading="eager"
priority
style={style}
onLoad={handleLoad}
/>
</div>
)}
</Link>
</div>
);
};
@@ -0,0 +1,143 @@
"use client"
import {
Home,
Settings,
Users,
Layers,
ChartArea,
ShieldHalf,
Building, UserRoundCog, Mail, PackageOpen, Logs, Megaphone, Blocks, Warehouse, BookOpen, Hammer,
ChevronsLeftRightEllipsis
} from "lucide-react";
import {SidebarGroupItem, SidebarMenuCustomBase} from "@/features/layout/components/menu-sidebar";
import {authClient} from "@/lib/auth/auth-client";
export const SidebarMenuCustomMain = () => {
const BASE_URL = `/dashboard`;
const {data: session, isPending, error} = authClient.useSession();
if (isPending) return null;
if (error || !session) {
return null;
}
const groupContentApplication: SidebarGroupItem["group_content"] = [
{title: "Dashboard", url: "/home", icon: Home, type: "item"},
];
const groupContent: SidebarGroupItem["group_content"] = [
{title: "Projects", url: "/projects", icon: Layers, details: true, type: "item"},
{title: "Statistics", url: "/statistics", icon: ChartArea, type: "item"},
{title: "Settings", url: "/settings", icon: Settings, details: true, type: "item"},
{
title: "Tools",
url: "/tools",
icon: Hammer,
details: true,
type: "collapse",
submenu: [
{title: "Migration", url: "/migration", icon: ChevronsLeftRightEllipsis, type: "item"},
]
}
];
const items: SidebarGroupItem[] = [
{
label: "Application",
type: "list",
group_content: groupContentApplication,
},
{
label: "Organization",
type: "list",
group_content: groupContent,
},
];
if (session?.user.role == "admin" || session?.user.role == "superadmin") {
items.push({
label: "Administration",
type: "list",
group_content: [
{
title: "Agents",
url: "/agents",
icon: ShieldHalf,
details: true,
type: "item"
},
{
title: "Notifications",
url: "/notifications",
icon: Megaphone,
details: true,
type: "collapse",
submenu: [
{title: "Channels", url: "/notifications/channels", icon: Blocks, type: "item"},
{title: "Activity Logs", url: "/notifications/logs", icon: Logs, type: "item"},
],
},
{
title: "Storages",
url: "/storages",
icon: Warehouse,
details: true,
type: "collapse",
submenu: [
{title: "Channels", url: "/storages/channels", icon: Blocks, type: "item"},
],
},
{
title: "Access management",
url: "/admin",
icon: UserRoundCog,
details: true,
type: "collapse",
submenu: [
{title: "Users", url: "/admin/users", icon: Users, type: "item"},
{
title: "Organizations",
url: "/admin/organizations",
icon: Building,
type: "item",
details: true
},
],
},
{
title: "Settings",
url: "/admin/settings",
icon: Settings,
type: "item",
},
],
});
}
items.push(
{
label: "Resources",
type: "list",
group_content: [
{
title: "Documentation",
url: "https://portabase.io/docs",
icon: BookOpen,
type: "item",
redirect: true,
not_from_base_url: true,
}
],
},
)
return <SidebarMenuCustomBase baseUrl={BASE_URL} items={items}/>;
};
@@ -0,0 +1,157 @@
"use client";
import {
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
} from "@/components/ui/sidebar";
import { ChevronRight, ChevronDown, MoreHorizontal } from "lucide-react";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@radix-ui/react-collapsible";
import React from "react";
import Link from "next/link";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { usePathname } from "next/navigation";
export type SidebarItem = {
title: string;
url: string;
redirect?: boolean;
not_from_base_url?: boolean;
icon: React.ElementType;
dropdown?: SidebarItem[];
submenu?: SidebarItem[];
details?: boolean;
type: "item" | "collapse";
};
export type SidebarGroupItem = {
label: string;
type: "list" | "collapse";
group_content: SidebarItem[];
};
type SidebarMenuCustomBaseProps = {
baseUrl: string;
items: SidebarGroupItem[];
};
export const SidebarMenuCustomBase = ({ baseUrl, items }: SidebarMenuCustomBaseProps) => {
const pathname = usePathname();
const normalize = (url: string) => url.split("?")[0].replace(/\/$/, "");
const isItemActive = (item: SidebarItem) => {
const fullUrl = item.not_from_base_url ? normalize(item.url) : normalize(`${baseUrl}${item.url}`);
if (item.details) return normalize(pathname).startsWith(fullUrl);
return normalize(pathname) === fullUrl;
};
const isSubActive = (item: SidebarItem) => {
if (!item.submenu) return false;
return item.submenu.some((sub) => isItemActive(sub));
};
return (
<SidebarMenu>
{items.map((group, index) => (
<Collapsible key={index} defaultOpen className="group/group-collapsible">
<SidebarGroup>
<SidebarGroupLabel asChild>
<CollapsibleTrigger disabled={group.type === "list"} className="flex w-full items-center text-sm font-medium text-sidebar-foreground/70">
{group.label}
{group.type === "collapse" && (
<ChevronDown className="ml-auto h-4 w-4 transition-transform group-data-[state=open]/group-collapsible:rotate-180" />
)}
</CollapsibleTrigger>
</SidebarGroupLabel>
<CollapsibleContent>
<SidebarGroupContent>
{group.group_content.map((item, idx) => {
if (item.type === "collapse" && item.submenu) {
const isChildActive = isSubActive(item);
return (
<Collapsible key={idx} asChild defaultOpen={isChildActive} className="group/collapsible">
<SidebarMenuItem>
<CollapsibleTrigger asChild>
<SidebarMenuButton className={cn(buttonVariants({ variant: "ghost", size: "lg" }), "justify-between")}>
<item.icon />
<span>{item.title}</span>
<ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{item.submenu.map((sub, subIdx) => (
<SidebarMenuSubItem key={subIdx}>
<SidebarMenuSubButton asChild isActive={isItemActive(sub)}>
<Link
href={sub.not_from_base_url ? sub.url : `${baseUrl}${sub.url}`}
className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "w-full justify-start")}
>
<sub.icon />
<span>{sub.title}</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
);
}
return (
<SidebarMenuItem key={idx}>
<SidebarMenuButton asChild isActive={isItemActive(item)} tooltip={item.title}>
<Link
href={item.redirect || item.not_from_base_url ? item.url : `${baseUrl}${item.url}`}
target={item.redirect ? "_blank" : ""}
className={cn(buttonVariants({ variant: "ghost", size: "lg" }), "justify-start")}
>
<item.icon />
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
{item.dropdown && (
<SidebarMenuAction showOnHover>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<MoreHorizontal />
</DropdownMenuTrigger>
<DropdownMenuContent side="right" align="start">
{item.dropdown.map((dropdown, dIdx) => (
<DropdownMenuItem key={dIdx} asChild>
<Link
href={dropdown.redirect || dropdown.not_from_base_url ? dropdown.url : `${baseUrl}${dropdown.url}`}
target={dropdown.redirect ? "_blank" : ""}
>
<span>{dropdown.title}</span>
</Link>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuAction>
)}
</SidebarMenuItem>
);
})}
</SidebarGroupContent>
</CollapsibleContent>
</SidebarGroup>
</Collapsible>
))}
</SidebarMenu>
);
};
+14
View File
@@ -0,0 +1,14 @@
import { twx } from "@/lib/twx";
import { cn } from "@/lib/utils";
export const Page = twx.div((props) => [cn(`flex flex-col h-full px-5 md:px-10 py-6`, props.className)]);
export const PageHeader = twx.div((props) => [cn(`flex justify-between`, props.className)]);
export const PageTitle = twx.h1((props) => [cn(`text-3xl font-bold mb-6 flex gap-4 items-center`, props.className)]);
export const PageDescription = twx.h2((props) => [cn(`text-s mb-6 `, props.className)]);
export const PageActions = twx.h1((props) => [cn(`flex gap-4 h-fit`, props.className)]);
export const PageContent = twx.div((props) => [cn(`h-full`, props.className)]);
@@ -0,0 +1,70 @@
"use client";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Tabs, TabsContent } from "@/components/ui/tabs";
import { ProfileSidebar } from "./profile-sidebar";
import type { AuthProviderConfig } from "@/lib/auth/config";
import { User, Session, Account } from "@/db/schema/02_user";
import { ProfileGeneral } from "@/features/profile/components/profile-general";
import { ProfileSecurity } from "@/features/profile/components/profile-security";
import { ProfileProviders } from "@/features/profile/components/profile-providers";
import { ProfileAccount } from "@/features/profile/components/profile-account";
import { ProfileAppearance } from "@/features/profile/components/profile-appearance";
type ProfileModalProps = {
open: boolean;
user: User;
sessions: Session[];
currentSession: Session;
accounts: Account[];
onOpenChange: (open: boolean) => void;
providers: AuthProviderConfig[];
apiEnabled: boolean;
avatarUrl?: string;
};
export const ProfileModal = ({ user, sessions, currentSession, accounts, open, onOpenChange, providers, apiEnabled, avatarUrl }: ProfileModalProps) => {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[95vw] h-[90vh] max-w-md lg:max-w-[1000px] lg:h-[800px] pb-6 p-0 overflow-hidden flex flex-col outline-none gap-0 rounded-xl bg-background">
<DialogHeader className="sr-only">
<DialogTitle>Settings</DialogTitle>
<DialogDescription>Manage your account settings</DialogDescription>
</DialogHeader>
<Tabs defaultValue="profile" orientation="vertical" className="flex flex-col lg:flex-row h-full w-full">
<ProfileSidebar user={user} />
<div className="flex-1 overflow-y-auto bg-background h-full scroll-smooth">
<TabsContent value="profile" className="mt-0 h-full p-6 lg:p-10 outline-none focus-visible:ring-0">
<ProfileGeneral user={user} avatarUrl={avatarUrl} />
</TabsContent>
<TabsContent value="security" className="mt-0 h-full p-6 lg:p-10 outline-none focus-visible:ring-0">
<ProfileSecurity
user={user}
sessions={sessions}
currentSession={currentSession}
credentialAccount={accounts.find((acc) => acc.providerId === "credential")!}
isPasswordEnabled={providers.some((p) => p.id === "credential")}
isPasskeyEnabled={providers.some((p) => p.id === "passkey")}
providers={providers}
/>
</TabsContent>
<TabsContent value="providers" className="mt-0 h-full p-6 lg:p-10 outline-none focus-visible:ring-0">
<ProfileProviders accounts={accounts} providers={providers} />
</TabsContent>
<TabsContent value="account" className="mt-0 h-full p-6 lg:p-10 outline-none focus-visible:ring-0">
<ProfileAccount user={user} apiEnabled={apiEnabled} />
</TabsContent>
<TabsContent value="appearance" className="mt-0 h-full p-6 lg:p-10 outline-none focus-visible:ring-0">
<ProfileAppearance />
</TabsContent>
</div>
</Tabs>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,50 @@
"use client";
import React from "react";
import { TabsList, TabsTrigger } from "@/components/ui/tabs";
import { UserIcon, Settings, Palette, ShieldHalf, Workflow, KeyRound } from "lucide-react";
import { User } from "@/db/schema/02_user";
interface ProfileSidebarProps {
user: User;
}
export function ProfileSidebar({ user }: ProfileSidebarProps) {
return (
<div className="w-full lg:w-[260px] flex-shrink-0 lg:border-r bg-muted/10 p-4 lg:p-6 flex flex-col gap-4 border-b lg:border-b-0">
<div className="flex items-center px-2 mb-2">
<span className="font-bold text-xl tracking-tight">Settings</span>
</div>
<TabsList className="flex flex-col h-auto w-full bg-transparent p-0 gap-1 justify-start items-stretch">
<SettingsTabTrigger value="profile" icon={<UserIcon className="w-4 h-4" />}>
Profile
</SettingsTabTrigger>
<SettingsTabTrigger value="security" icon={<ShieldHalf className="w-4 h-4" />}>
Security & Access
</SettingsTabTrigger>
<SettingsTabTrigger value="providers" icon={<Workflow className="w-4 h-4" />}>
Connected Accounts
</SettingsTabTrigger>
<SettingsTabTrigger value="account" icon={<Settings className="w-4 h-4" />}>
Account
</SettingsTabTrigger>
<SettingsTabTrigger value="appearance" icon={<Palette className="w-4 h-4" />}>
Appearance
</SettingsTabTrigger>
</TabsList>
</div>
);
}
function SettingsTabTrigger({ value, icon, children }: { value: string; icon: React.ReactNode; children: React.ReactNode }) {
return (
<TabsTrigger
value={value}
className="w-full justify-start gap-3 px-3 py-2.5 rounded-md transition-all whitespace-nowrap flex-shrink-0 text-sm data-[state=active]:bg-background data-[state=active]:text-primary data-[state=active]:shadow-sm data-[state=active]:font-medium hover:bg-muted/50 hover:text-foreground text-muted-foreground"
>
{icon}
{children}
</TabsTrigger>
);
}
@@ -0,0 +1,19 @@
"use client";
import { useSidebar } from "@/components/ui/sidebar";
import { env } from "@/env.mjs";
export type SideBarFooterCreditProps = {};
export const SideBarFooterCredit = (props: SideBarFooterCreditProps) => {
const { state } = useSidebar();
return (
<>
{state === "expanded" && (
<div className="text-center mb-2">
<h1 className="text-[10px]">Portabase Community Edition v{env.NEXT_PUBLIC_PROJECT_VERSION}</h1>
</div>
)}
</>
);
};