mirror of
https://github.com/Portabase/portabase.git
synced 2026-07-14 11:16:13 +02:00
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:
co-authored by
Théo LAGACHE
charles-gauthereau
parent
40d3228db6
commit
40247efddd
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user