فهرست منبع

place holder landing pages

Milo Schwartz 7 ماه پیش
والد
کامیت
b78e7a324d

+ 2 - 0
server/routers/external.ts

@@ -336,6 +336,8 @@ authenticated.get(
     accessToken.listAccessTokens
 );
 
+authenticated.get(`/org/:orgId/overview`, verifyOrgAccess, org.getOrgOverview);
+
 unauthenticated.get("/resource/:resourceId/auth", resource.getResourceAuthInfo);
 
 // authenticated.get(

+ 148 - 0
server/routers/org/getOrgOverview.ts

@@ -0,0 +1,148 @@
+import { Request, Response, NextFunction } from "express";
+import { z } from "zod";
+import { db } from "@server/db";
+import {
+    orgs,
+    resources,
+    roles,
+    sites,
+    userOrgs,
+    userResources,
+    users,
+    userSites
+} from "@server/db/schema";
+import { and, count, eq, inArray } from "drizzle-orm";
+import response from "@server/utils/response";
+import HttpCode from "@server/types/HttpCode";
+import createHttpError from "http-errors";
+import logger from "@server/logger";
+
+const getOrgParamsSchema = z
+    .object({
+        orgId: z.string()
+    })
+    .strict();
+
+export type GetOrgOverviewResponse = {
+    orgName: string;
+    orgId: string;
+    userRoleName: string;
+    numSites: number;
+    numUsers: number;
+    numResources: number;
+    isAdmin: boolean;
+    isOwner: boolean;
+};
+
+export async function getOrgOverview(
+    req: Request,
+    res: Response,
+    next: NextFunction
+): Promise<any> {
+    try {
+        const parsedParams = getOrgParamsSchema.safeParse(req.params);
+        if (!parsedParams.success) {
+            return next(
+                createHttpError(
+                    HttpCode.BAD_REQUEST,
+                    parsedParams.error.errors.map((e) => e.message).join(", ")
+                )
+            );
+        }
+
+        const { orgId } = parsedParams.data;
+
+        const org = await db
+            .select()
+            .from(orgs)
+            .where(eq(orgs.orgId, orgId))
+            .limit(1);
+
+        if (org.length === 0) {
+            return next(
+                createHttpError(
+                    HttpCode.NOT_FOUND,
+                    `Organization with ID ${orgId} not found`
+                )
+            );
+        }
+
+        if (!req.userOrg) {
+            return next(
+                createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated")
+            );
+        }
+
+        const allSiteIds = await db
+            .select({
+                siteId: sites.siteId
+            })
+            .from(sites)
+            .where(eq(sites.orgId, orgId));
+
+        const [{ numSites }] = await db
+            .select({ numSites: count() })
+            .from(userSites)
+            .where(
+                and(
+                    eq(userSites.userId, req.userOrg.userId),
+                    inArray(
+                        userSites.siteId,
+                        allSiteIds.map((site) => site.siteId)
+                    )
+                )
+            );
+
+        const allResourceIds = await db
+            .select({
+                resourceId: resources.resourceId
+            })
+            .from(resources)
+            .where(eq(resources.orgId, orgId));
+
+        const [{ numResources }] = await db
+            .select({ numResources: count() })
+            .from(userResources)
+            .where(
+                and(
+                    eq(userResources.userId, req.userOrg.userId),
+                    inArray(
+                        userResources.resourceId,
+                        allResourceIds.map((resource) => resource.resourceId)
+                    )
+                )
+            );
+
+        const [{ numUsers }] = await db
+            .select({ numUsers: count() })
+            .from(userOrgs)
+            .where(eq(userOrgs.orgId, orgId));
+
+        const [role] = await db
+            .select()
+            .from(roles)
+            .where(eq(roles.roleId, req.userOrg.roleId));
+
+        return response<GetOrgOverviewResponse>(res, {
+            data: {
+                orgName: org[0].name,
+                orgId: org[0].orgId,
+                userRoleName: role.name,
+                numSites,
+                numUsers,
+                numResources,
+                isAdmin: role.name === "Admin",
+                isOwner: req.userOrg?.isOwner || false
+            },
+            success: true,
+            error: false,
+            message: "Organization overview retrieved successfully",
+            status: HttpCode.OK
+        });
+    } catch (error) {
+        logger.error(error);
+        return next(
+            createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
+        );
+    }
+}

+ 2 - 1
server/routers/org/index.ts

@@ -3,4 +3,5 @@ export * from "./createOrg";
 export * from "./deleteOrg";
 export * from "./updateOrg";
 export * from "./listOrgs";
-export * from "./checkId";
+export * from "./checkId";
+export * from "./getOrgOverview";

+ 102 - 0
src/app/[orgId]/components/OrganizationLandingCard.tsx

@@ -0,0 +1,102 @@
+"use client";
+
+import { useState } from "react";
+import Link from "next/link";
+import {
+    Card,
+    CardHeader,
+    CardTitle,
+    CardContent,
+    CardFooter
+} from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import { Users, Globe, Database, Cog, Settings, Waypoints, Combine } from "lucide-react";
+
+interface OrgStat {
+    label: string;
+    value: number;
+    icon: React.ReactNode;
+}
+
+type OrganizationLandingCardProps = {
+    overview: {
+        orgName: string;
+        stats: {
+            sites: number;
+            resources: number;
+            users: number;
+        };
+        userRole: string;
+        isAdmin: boolean;
+        isOwner: boolean;
+        orgId: string;
+    };
+};
+
+export default function OrganizationLandingCard(
+    props: OrganizationLandingCardProps
+) {
+    const [orgData] = useState(props);
+
+    const orgStats: OrgStat[] = [
+        {
+            label: "Sites",
+            value: orgData.overview.stats.sites,
+            icon: <Combine className="h-6 w-6" />
+        },
+        {
+            label: "Resources",
+            value: orgData.overview.stats.resources,
+            icon: <Waypoints className="h-6 w-6" />
+        },
+        {
+            label: "Users",
+            value: orgData.overview.stats.users,
+            icon: <Users className="h-6 w-6" />
+        }
+    ];
+
+    return (
+        <Card>
+            <CardHeader>
+                <CardTitle className="flex items-center text-3xl font-bold">
+                    {orgData.overview.orgName}
+                </CardTitle>
+            </CardHeader>
+            <CardContent>
+                <div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
+                    {orgStats.map((stat, index) => (
+                        <div
+                            key={index}
+                            className="flex flex-col items-center p-4 bg-secondary rounded-lg"
+                        >
+                            {stat.icon}
+                            <span className="mt-2 text-2xl font-bold">
+                                {stat.value}
+                            </span>
+                            <span className="text-sm text-muted-foreground">
+                                {stat.label}
+                            </span>
+                        </div>
+                    ))}
+                </div>
+                <div className="text-center text-lg">
+                    Your role:{" "}
+                    <span className="font-semibold">
+                        {orgData.overview.isOwner ? "Owner" : orgData.overview.userRole}
+                    </span>
+                </div>
+            </CardContent>
+            {orgData.overview.isAdmin && (
+                <CardFooter className="flex justify-center">
+                    <Link href={`/${orgData.overview.orgId}/settings`}>
+                        <Button size="lg" className="w-full md:w-auto">
+                            <Settings className="mr-2 h-4 w-4" />
+                            Organization Settings
+                        </Button>
+                    </Link>
+                </CardFooter>
+            )}
+        </Card>
+    );
+}

+ 7 - 1
src/app/[orgId]/layout.tsx

@@ -1,6 +1,8 @@
 import { internal } from "@app/api";
 import { authCookieHeader } from "@app/api/cookies";
+import ProfileIcon from "@app/components/ProfileIcon";
 import { verifySession } from "@app/lib/auth/verifySession";
+import UserProvider from "@app/providers/UserProvider";
 import { GetOrgResponse } from "@server/routers/org";
 import { GetOrgUserResponse } from "@server/routers/user";
 import { AxiosResponse } from "axios";
@@ -47,5 +49,9 @@ export default async function OrgLayout(props: {
         redirect(`/`);
     }
 
-    return <>{props.children}</>;
+    return (
+        <>
+            {props.children}
+        </>
+    );
 }

+ 54 - 5
src/app/[orgId]/page.tsx

@@ -1,10 +1,13 @@
-import { internal } from "@app/api";
-import { authCookieHeader } from "@app/api/cookies";
+import ProfileIcon from "@app/components/ProfileIcon";
 import { verifySession } from "@app/lib/auth/verifySession";
-import { GetOrgUserResponse } from "@server/routers/user";
+import UserProvider from "@app/providers/UserProvider";
+import { cache } from "react";
+import OrganizationLandingCard from "./components/OrganizationLandingCard";
+import { GetOrgOverviewResponse } from "@server/routers/org/getOrgOverview";
+import { internal } from "@app/api";
 import { AxiosResponse } from "axios";
+import { authCookieHeader } from "@app/api/cookies";
 import { redirect } from "next/navigation";
-import { cache } from "react";
 
 type OrgPageProps = {
     params: Promise<{ orgId: string }>;
@@ -14,9 +17,55 @@ export default async function OrgPage(props: OrgPageProps) {
     const params = await props.params;
     const orgId = params.orgId;
 
+    const getUser = cache(verifySession);
+    const user = await getUser();
+
+    let redirectToSettings = false;
+    let overview: GetOrgOverviewResponse | undefined;
+    try {
+        const res = await internal.get<AxiosResponse<GetOrgOverviewResponse>>(
+            `/org/${orgId}/overview`,
+            await authCookieHeader()
+        );
+        overview = res.data.data;
+
+        if (overview.isAdmin || overview.isOwner) {
+            redirectToSettings = true;
+        }
+    } catch (e) {}
+
+    if (redirectToSettings) {
+        redirect(`/${orgId}/settings`);
+    }
+
     return (
         <>
-            <p>Welcome to {orgId} dashboard</p>
+            <div className="p-3">
+                {user && (
+                    <UserProvider user={user}>
+                        <ProfileIcon />
+                    </UserProvider>
+                )}
+
+                {overview && (
+                    <div className="w-full max-w-4xl mx-auto md:mt-32 mt-4">
+                        <OrganizationLandingCard
+                            overview={{
+                                orgId: overview.orgId,
+                                orgName: overview.orgName,
+                                stats: {
+                                    users: overview.numUsers,
+                                    sites: overview.numSites,
+                                    resources: overview.numResources
+                                },
+                                isAdmin: overview.isAdmin,
+                                isOwner: overview.isOwner,
+                                userRole: overview.userRoleName
+                            }}
+                        />
+                    </div>
+                )}
+            </div>
         </>
     );
 }

+ 8 - 14
src/app/[orgId]/settings/access/roles/page.tsx

@@ -18,13 +18,10 @@ export default async function RolesPage(props: RolesPageProps) {
 
     let roles: ListRolesResponse["roles"] = [];
     const res = await internal
-        .get<AxiosResponse<ListRolesResponse>>(
-            `/org/${params.orgId}/roles`,
-            await authCookieHeader()
-        )
-        .catch((e) => {
-            console.error(e);
-        });
+        .get<
+            AxiosResponse<ListRolesResponse>
+        >(`/org/${params.orgId}/roles`, await authCookieHeader())
+        .catch((e) => {});
 
     if (res && res.status === 200) {
         roles = res.data.data.roles;
@@ -33,13 +30,10 @@ export default async function RolesPage(props: RolesPageProps) {
     let org: GetOrgResponse | null = null;
     const getOrg = cache(async () =>
         internal
-            .get<AxiosResponse<GetOrgResponse>>(
-                `/org/${params.orgId}`,
-                await authCookieHeader()
-            )
-            .catch((e) => {
-                console.error(e);
-            })
+            .get<
+                AxiosResponse<GetOrgResponse>
+            >(`/org/${params.orgId}`, await authCookieHeader())
+            .catch((e) => {})
     );
     const orgRes = await getOrg();
 

+ 8 - 12
src/app/[orgId]/settings/access/users/page.tsx

@@ -23,13 +23,10 @@ export default async function UsersPage(props: UsersPageProps) {
 
     let users: ListUsersResponse["users"] = [];
     const res = await internal
-        .get<AxiosResponse<ListUsersResponse>>(
-            `/org/${params.orgId}/users`,
-            await authCookieHeader()
-        )
-        .catch((e) => {
-            console.error(e);
-        });
+        .get<
+            AxiosResponse<ListUsersResponse>
+        >(`/org/${params.orgId}/users`, await authCookieHeader())
+        .catch((e) => {});
 
     if (res && res.status === 200) {
         users = res.data.data.users;
@@ -38,10 +35,9 @@ export default async function UsersPage(props: UsersPageProps) {
     let org: GetOrgResponse | null = null;
     const getOrg = cache(async () =>
         internal
-            .get<AxiosResponse<GetOrgResponse>>(
-                `/org/${params.orgId}`,
-                await authCookieHeader()
-            )
+            .get<
+                AxiosResponse<GetOrgResponse>
+            >(`/org/${params.orgId}`, await authCookieHeader())
             .catch((e) => {
                 console.error(e);
             })
@@ -58,7 +54,7 @@ export default async function UsersPage(props: UsersPageProps) {
             email: user.email,
             status: "Confirmed",
             role: user.isOwner ? "Owner" : user.roleName || "Member",
-            isOwner: user.isOwner || false,
+            isOwner: user.isOwner || false
         };
     });
 

+ 1 - 3
src/app/[orgId]/settings/layout.tsx

@@ -91,9 +91,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
         if (res && res.data.data.orgs) {
             orgs = res.data.data.orgs;
         }
-    } catch (e) {
-        console.error("Error fetching orgs", e);
-    }
+    } catch (e) {}
 
     return (
         <>

+ 4 - 6
src/app/[orgId]/settings/resources/page.tsx

@@ -19,20 +19,18 @@ export default async function ResourcesPage(props: ResourcesPageProps) {
     try {
         const res = await internal.get<AxiosResponse<ListResourcesResponse>>(
             `/org/${params.orgId}/resources`,
-            await authCookieHeader(),
+            await authCookieHeader()
         );
         resources = res.data.data.resources;
-    } catch (e) {
-        console.error("Error fetching resources", e);
-    }
+    } catch (e) {}
 
     let org = null;
     try {
         const getOrg = cache(async () =>
             internal.get<AxiosResponse<GetOrgResponse>>(
                 `/org/${params.orgId}`,
-                await authCookieHeader(),
-            ),
+                await authCookieHeader()
+            )
         );
         const res = await getOrg();
         org = res.data.data;

+ 1 - 3
src/app/[orgId]/settings/share-links/page.tsx

@@ -24,9 +24,7 @@ export default async function ShareLinksPage(props: ShareLinksPageProps) {
             await authCookieHeader()
         );
         tokens = res.data.data.accessTokens;
-    } catch (e) {
-        console.error("Error fetching tokens", e);
-    }
+    } catch (e) {}
 
     let org = null;
     try {

+ 3 - 5
src/app/[orgId]/settings/sites/page.tsx

@@ -15,12 +15,10 @@ export default async function SitesPage(props: SitesPageProps) {
     try {
         const res = await internal.get<AxiosResponse<ListSitesResponse>>(
             `/org/${params.orgId}/sites`,
-            await authCookieHeader(),
+            await authCookieHeader()
         );
         sites = res.data.data.sites;
-    } catch (e) {
-        console.error("Error fetching sites", e);
-    }
+    } catch (e) {}
 
     function formatSize(mb: number): string {
         if (mb >= 1024 * 1024) {
@@ -41,7 +39,7 @@ export default async function SitesPage(props: SitesPageProps) {
             mbOut: formatSize(site.megabytesOut || 0),
             orgId: params.orgId,
             type: site.type as any,
-            online: site.online,
+            online: site.online
         };
     });
 

+ 16 - 1
src/app/auth/layout.tsx

@@ -1,8 +1,12 @@
+import ProfileIcon from "@app/components/ProfileIcon";
+import { verifySession } from "@app/lib/auth/verifySession";
+import UserProvider from "@app/providers/UserProvider";
 import { Metadata } from "next";
+import { cache } from "react";
 
 export const metadata: Metadata = {
     title: `Auth - Pangolin`,
-    description: "",
+    description: ""
 };
 
 type AuthLayoutProps = {
@@ -10,8 +14,19 @@ type AuthLayoutProps = {
 };
 
 export default async function AuthLayout({ children }: AuthLayoutProps) {
+    const getUser = cache(verifySession);
+    const user = await getUser();
+
     return (
         <>
+            {user && (
+                <UserProvider user={user}>
+                    <div>
+                        <ProfileIcon />
+                    </div>
+                </UserProvider>
+            )}
+
             <div className="w-full max-w-md mx-auto p-3 md:mt-32">
                 {children}
             </div>

+ 97 - 0
src/app/components/OrganizationLanding.tsx

@@ -0,0 +1,97 @@
+"use client";
+
+import { useState } from "react";
+import {
+    Card,
+    CardHeader,
+    CardTitle,
+    CardContent,
+    CardDescription
+} from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import Link from "next/link";
+import { ArrowRight, Plus } from "lucide-react";
+interface Organization {
+    id: string;
+    name: string;
+}
+
+interface OrganizationLandingProps {
+    organizations?: Organization[];
+    disableCreateOrg?: boolean;
+}
+
+export default function OrganizationLanding({
+    organizations = [],
+    disableCreateOrg = false
+}: OrganizationLandingProps) {
+    const [selectedOrg, setSelectedOrg] = useState<string | null>(null);
+
+    const handleOrgClick = (orgId: string) => {
+        setSelectedOrg(orgId);
+    };
+
+    function getDescriptionText() {
+        if (organizations.length === 0) {
+            if (!disableCreateOrg) {
+                return "You are not currently a member of any organizations. Create an organization to get started.";
+            } else {
+                return "You are not currently a member of any organizations.";
+            }
+        }
+
+        return `You're a member of ${organizations.length} ${
+            organizations.length === 1 ? "organization" : "organizations"
+        }.`;
+    }
+
+    return (
+        <Card>
+            <CardHeader>
+                <CardTitle>Welcome to Pangolin</CardTitle>
+                <CardDescription>{getDescriptionText()}</CardDescription>
+            </CardHeader>
+            <CardContent>
+                {organizations.length === 0 ? (
+                    disableCreateOrg ? (
+                        <p className="text-center text-muted-foreground">
+                            You are not currently a member of any organizations.
+                        </p>
+                    ) : (
+                        <Link href="/setup">
+                            <Button
+                                className="w-full h-auto py-3 text-lg"
+                                size="lg"
+                            >
+                                <Plus className="mr-2 h-5 w-5" />
+                                Create an Organization
+                            </Button>
+                        </Link>
+                    )
+                ) : (
+                    <ul className="space-y-2">
+                        {organizations.map((org) => (
+                            <li key={org.id}>
+                                <Link href={`/${org.id}/settings`}>
+                                    <Button
+                                        variant="outline"
+                                        className={`flex items-center justify-between w-full h-auto py-3 ${
+                                            selectedOrg === org.id
+                                                ? "ring-2 ring-primary"
+                                                : ""
+                                        }`}
+                                    >
+                                        <div className="truncate">
+                                            {org.name}
+                                        </div>
+                                        <ArrowRight size={20} />
+                                    </Button>
+                                </Link>
+                            </li>
+                        ))}
+                    </ul>
+                )}
+            </CardContent>
+        </Card>
+    );
+}

+ 0 - 3
src/app/layout.tsx

@@ -24,9 +24,6 @@ export default async function RootLayout({
 }>) {
     const version = process.env.APP_VERSION;
 
-    const getUser = cache(verifySession);
-    const user = await getUser();
-
     return (
         <html suppressHydrationWarning>
             <body className={`${font.className}`}>

+ 3 - 3
src/app/not-found.tsx

@@ -3,11 +3,11 @@ import Link from "next/link";
 export default async function NotFound() {
     return (
         <div className="w-full max-w-md mx-auto p-3 md:mt-32 text-center">
-            <h1 className="text-6xl font-bold text-gray-800 mb-4">404</h1>
-            <h2 className="text-2xl font-semibold text-gray-600 mb-4">
+            <h1 className="text-6xl font-bold mb-4">404</h1>
+            <h2 className="text-2xl font-semibold text-neutral-500 mb-4">
                 Page Not Found
             </h2>
-            <p className="text-gray-500 mb-8">
+            <p className="text-neutral-500 dark:text-neutral-700 mb-8">
                 Oops! The page you're looking for doesn't exist.
             </p>
         </div>

+ 30 - 22
src/app/page.tsx

@@ -1,5 +1,6 @@
 import { internal } from "@app/api";
 import { authCookieHeader } from "@app/api/cookies";
+import ProfileIcon from "@app/components/ProfileIcon";
 import { verifySession } from "@app/lib/auth/verifySession";
 import UserProvider from "@app/providers/UserProvider";
 import { ListOrgsResponse } from "@server/routers/org";
@@ -8,11 +9,15 @@ import { ArrowUpRight } from "lucide-react";
 import Link from "next/link";
 import { redirect } from "next/navigation";
 import { cache } from "react";
+import OrganizationLanding from "./components/OrganizationLanding";
 
 export const dynamic = "force-dynamic";
 
 export default async function Page(props: {
-    searchParams: Promise<{ redirect: string | undefined, t: string | undefined }>;
+    searchParams: Promise<{
+        redirect: string | undefined;
+        t: string | undefined;
+    }>;
 }) {
     const params = await props.searchParams; // this is needed to prevent static optimization
 
@@ -42,39 +47,42 @@ export default async function Page(props: {
     try {
         const res = await internal.get<AxiosResponse<ListOrgsResponse>>(
             `/orgs`,
-            await authCookieHeader(),
+            await authCookieHeader()
         );
 
         if (res && res.data.data.orgs) {
             orgs = res.data.data.orgs;
         }
-    } catch (e) {
-        console.error(e);
-    }
+    } catch (e) {}
 
     if (!orgs.length) {
-        redirect("/setup");
+        if (
+            process.env.DISABLE_USER_CREATE_ORG === "false" ||
+            user.serverAdmin
+        ) {
+            redirect("/setup");
+        }
     }
 
     return (
         <>
-            <UserProvider user={user}>
-                <p>Logged in as {user.email}</p>
-            </UserProvider>
-
-            <div className="mt-4">
-                {orgs.map((org) => (
-                    <Link
-                        key={org.orgId}
-                        href={`/${org.orgId}/settings`}
-                        className="text-primary underline"
-                    >
-                        <div className="flex items-center">
-                            {org.name}
-                            <ArrowUpRight className="w-4 h-4" />
+            <div className="p-3">
+                {user && (
+                    <UserProvider user={user}>
+                        <div>
+                            <ProfileIcon />
                         </div>
-                    </Link>
-                ))}
+                    </UserProvider>
+                )}
+
+                <div className="w-full max-w-md mx-auto md:mt-32 mt-4">
+                    <OrganizationLanding
+                        organizations={orgs.map((org) => ({
+                            name: org.name,
+                            id: org.orgId
+                        }))}
+                    />
+                </div>
             </div>
         </>
     );

+ 17 - 1
src/app/setup/layout.tsx

@@ -1,4 +1,6 @@
+import ProfileIcon from "@app/components/ProfileIcon";
 import { verifySession } from "@app/lib/auth/verifySession";
+import UserProvider from "@app/providers/UserProvider";
 import { Metadata } from "next";
 import { redirect } from "next/navigation";
 import { cache } from "react";
@@ -29,6 +31,20 @@ export default async function SetupLayout({
     }
 
     return (
-        <div className="w-full max-w-2xl mx-auto p-3 md:mt-32">{children}</div>
+        <>
+            <div className="p-3">
+                {user && (
+                    <UserProvider user={user}>
+                        <div>
+                            <ProfileIcon />
+                        </div>
+                    </UserProvider>
+                )}
+
+                <div className="w-full max-w-2xl mx-auto md:mt-32 mt-4">
+                    {children}
+                </div>
+            </div>
+        </>
     );
 }

+ 2 - 2
src/components/DataTablePagination.tsx

@@ -47,8 +47,8 @@ export function DataTablePagination<TData>({
                 </Select>
             </div>
 
-            <div className="flex items-center space-x-6 lg:space-x-8">
-                <div className="flex w-[100px] items-center justify-center text-sm font-medium">
+            <div className="flex items-center space-x-3 lg:space-x-8">
+                <div className="flex items-center justify-center text-sm font-medium">
                     Page {table.getState().pagination.pageIndex + 1} of{" "}
                     {table.getPageCount()}
                 </div>

+ 4 - 151
src/components/Header.tsx

@@ -1,7 +1,5 @@
 "use client";
 
-import { createApiClient } from "@app/api";
-import { Avatar, AvatarFallback } from "@app/components/ui/avatar";
 import { Button } from "@app/components/ui/button";
 import {
     Command,
@@ -12,39 +10,20 @@ import {
     CommandList,
     CommandSeparator
 } from "@app/components/ui/command";
-import {
-    DropdownMenu,
-    DropdownMenuContent,
-    DropdownMenuItem,
-    DropdownMenuLabel,
-    DropdownMenuSeparator,
-    DropdownMenuTrigger
-} from "@app/components/ui/dropdown-menu";
 import {
     Popover,
     PopoverContent,
     PopoverTrigger
 } from "@app/components/ui/popover";
 import { useEnvContext } from "@app/hooks/useEnvContext";
-import { useToast } from "@app/hooks/useToast";
-import { cn, formatAxiosError } from "@app/lib/utils";
+import { cn } from "@app/lib/utils";
 import { ListOrgsResponse } from "@server/routers/org";
-import {
-    Check,
-    ChevronsUpDown,
-    Laptop,
-    LogOut,
-    Moon,
-    Plus,
-    Sun
-} from "lucide-react";
-import { useTheme } from "next-themes";
+import { Check, ChevronsUpDown, Plus } from "lucide-react";
 import Link from "next/link";
 import { useRouter } from "next/navigation";
 import { useState } from "react";
-import Enable2FaForm from "./Enable2FaForm";
 import { useUserContext } from "@app/hooks/useUserContext";
-import Disable2FaForm from "./Disable2FaForm";
+import ProfileIcon from "./ProfileIcon";
 
 type HeaderProps = {
     orgId?: string;
@@ -52,144 +31,18 @@ type HeaderProps = {
 };
 
 export function Header({ orgId, orgs }: HeaderProps) {
-    const { toast } = useToast();
-    const { setTheme, theme } = useTheme();
-
     const { user, updateUser } = useUserContext();
 
     const [open, setOpen] = useState(false);
-    const [userTheme, setUserTheme] = useState<"light" | "dark" | "system">(
-        theme as "light" | "dark" | "system"
-    );
-
-    const [openEnable2fa, setOpenEnable2fa] = useState(false);
-    const [openDisable2fa, setOpenDisable2fa] = useState(false);
 
     const router = useRouter();
 
     const { env } = useEnvContext();
 
-    const api = createApiClient({ env });
-
-    function getInitials() {
-        return user.email.substring(0, 2).toUpperCase();
-    }
-
-    function logout() {
-        api.post("/auth/logout")
-            .catch((e) => {
-                console.error("Error logging out", e);
-                toast({
-                    title: "Error logging out",
-                    description: formatAxiosError(e, "Error logging out")
-                });
-            })
-            .then(() => {
-                router.push("/auth/login");
-            });
-    }
-
-    function handleThemeChange(theme: "light" | "dark" | "system") {
-        setUserTheme(theme);
-        setTheme(theme);
-    }
-
     return (
         <>
-            <Enable2FaForm open={openEnable2fa} setOpen={setOpenEnable2fa} />
-            <Disable2FaForm open={openDisable2fa} setOpen={setOpenDisable2fa} />
-
             <div className="flex items-center justify-between">
-                <div className="flex items-center gap-4">
-                    <DropdownMenu>
-                        <DropdownMenuTrigger asChild>
-                            <Button
-                                variant="outline"
-                                className="relative h-10 w-10 rounded-full"
-                            >
-                                <Avatar className="h-9 w-9">
-                                    <AvatarFallback>
-                                        {getInitials()}
-                                    </AvatarFallback>
-                                </Avatar>
-                            </Button>
-                        </DropdownMenuTrigger>
-                        <DropdownMenuContent
-                            className="w-56"
-                            align="start"
-                            forceMount
-                        >
-                            <DropdownMenuLabel className="font-normal">
-                                <div className="flex flex-col space-y-1">
-                                    <p className="text-sm font-medium leading-none">
-                                        Signed in as
-                                    </p>
-                                    <p className="text-xs leading-none text-muted-foreground">
-                                        {user.email}
-                                    </p>
-                                </div>
-                                {user.serverAdmin && (
-                                    <p className="text-xs leading-none text-muted-foreground mt-2">
-                                        Server Admin
-                                    </p>
-                                )}
-                            </DropdownMenuLabel>
-                            <DropdownMenuSeparator />
-                            {!user.twoFactorEnabled && (
-                                <DropdownMenuItem
-                                    onClick={() => setOpenEnable2fa(true)}
-                                >
-                                    <span>Enable Two-factor</span>
-                                </DropdownMenuItem>
-                            )}
-                            {user.twoFactorEnabled && (
-                                <DropdownMenuItem
-                                    onClick={() => setOpenDisable2fa(true)}
-                                >
-                                    <span>Disable Two-factor</span>
-                                </DropdownMenuItem>
-                            )}
-                            <DropdownMenuSeparator />
-                            <DropdownMenuLabel>Theme</DropdownMenuLabel>
-                            {(["light", "dark", "system"] as const).map(
-                                (themeOption) => (
-                                    <DropdownMenuItem
-                                        key={themeOption}
-                                        onClick={() =>
-                                            handleThemeChange(themeOption)
-                                        }
-                                    >
-                                        {themeOption === "light" && (
-                                            <Sun className="mr-2 h-4 w-4" />
-                                        )}
-                                        {themeOption === "dark" && (
-                                            <Moon className="mr-2 h-4 w-4" />
-                                        )}
-                                        {themeOption === "system" && (
-                                            <Laptop className="mr-2 h-4 w-4" />
-                                        )}
-                                        <span className="capitalize">
-                                            {themeOption}
-                                        </span>
-                                        {userTheme === themeOption && (
-                                            <span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
-                                                <span className="h-2 w-2 rounded-full bg-primary"></span>
-                                            </span>
-                                        )}
-                                    </DropdownMenuItem>
-                                )
-                            )}
-                            <DropdownMenuSeparator />
-                            <DropdownMenuItem onClick={() => logout()}>
-                                <LogOut className="mr-2 h-4 w-4" />
-                                <span>Log out</span>
-                            </DropdownMenuItem>
-                        </DropdownMenuContent>
-                    </DropdownMenu>
-                    <span className="truncate max-w-[150px] md:max-w-none font-medium">
-                        {user.email}
-                    </span>
-                </div>
+                <ProfileIcon />
 
                 <div className="flex items-center">
                     <div className="hidden md:block">

+ 158 - 0
src/components/ProfileIcon.tsx

@@ -0,0 +1,158 @@
+"use client";
+
+import { createApiClient } from "@app/api";
+import { Avatar, AvatarFallback } from "@app/components/ui/avatar";
+import { Button } from "@app/components/ui/button";
+import {
+    DropdownMenu,
+    DropdownMenuContent,
+    DropdownMenuItem,
+    DropdownMenuLabel,
+    DropdownMenuSeparator,
+    DropdownMenuTrigger
+} from "@app/components/ui/dropdown-menu";
+import { useEnvContext } from "@app/hooks/useEnvContext";
+import { useToast } from "@app/hooks/useToast";
+import { formatAxiosError } from "@app/lib/utils";
+import { Laptop, LogOut, Moon, Sun } from "lucide-react";
+import { useTheme } from "next-themes";
+import { useRouter } from "next/navigation";
+import { useState } from "react";
+import { useUserContext } from "@app/hooks/useUserContext";
+import Disable2FaForm from "./Disable2FaForm";
+import Enable2FaForm from "./Enable2FaForm";
+
+export default function ProfileIcon() {
+    const { toast } = useToast();
+    const { setTheme, theme } = useTheme();
+    const { env } = useEnvContext();
+    const api = createApiClient({ env });
+    const { user, updateUser } = useUserContext();
+    const router = useRouter();
+
+    const [userTheme, setUserTheme] = useState<"light" | "dark" | "system">(
+        theme as "light" | "dark" | "system"
+    );
+
+    const [openEnable2fa, setOpenEnable2fa] = useState(false);
+    const [openDisable2fa, setOpenDisable2fa] = useState(false);
+
+    function getInitials() {
+        return user.email.substring(0, 2).toUpperCase();
+    }
+
+    function handleThemeChange(theme: "light" | "dark" | "system") {
+        setUserTheme(theme);
+        setTheme(theme);
+    }
+
+    function logout() {
+        api.post("/auth/logout")
+            .catch((e) => {
+                console.error("Error logging out", e);
+                toast({
+                    title: "Error logging out",
+                    description: formatAxiosError(e, "Error logging out")
+                });
+            })
+            .then(() => {
+                router.push("/auth/login");
+            });
+    }
+
+    return (
+        <>
+            <Enable2FaForm open={openEnable2fa} setOpen={setOpenEnable2fa} />
+            <Disable2FaForm open={openDisable2fa} setOpen={setOpenDisable2fa} />
+
+            <div className="flex items-center gap-4 flex-grow min-w-0">
+                <DropdownMenu>
+                    <DropdownMenuTrigger asChild>
+                        <Button
+                            variant="outline"
+                            className="relative h-10 w-10 rounded-full"
+                        >
+                            <Avatar className="h-9 w-9">
+                                <AvatarFallback>{getInitials()}</AvatarFallback>
+                            </Avatar>
+                        </Button>
+                    </DropdownMenuTrigger>
+                    <DropdownMenuContent
+                        className="w-56"
+                        align="start"
+                        forceMount
+                    >
+                        <DropdownMenuLabel className="font-normal">
+                            <div className="flex flex-col space-y-1">
+                                <p className="text-sm font-medium leading-none">
+                                    Signed in as
+                                </p>
+                                <p className="text-xs leading-none text-muted-foreground">
+                                    {user.email}
+                                </p>
+                            </div>
+                            {user.serverAdmin && (
+                                <p className="text-xs leading-none text-muted-foreground mt-2">
+                                    Server Admin
+                                </p>
+                            )}
+                        </DropdownMenuLabel>
+                        <DropdownMenuSeparator />
+                        {!user.twoFactorEnabled && (
+                            <DropdownMenuItem
+                                onClick={() => setOpenEnable2fa(true)}
+                            >
+                                <span>Enable Two-factor</span>
+                            </DropdownMenuItem>
+                        )}
+                        {user.twoFactorEnabled && (
+                            <DropdownMenuItem
+                                onClick={() => setOpenDisable2fa(true)}
+                            >
+                                <span>Disable Two-factor</span>
+                            </DropdownMenuItem>
+                        )}
+                        <DropdownMenuSeparator />
+                        <DropdownMenuLabel>Theme</DropdownMenuLabel>
+                        {(["light", "dark", "system"] as const).map(
+                            (themeOption) => (
+                                <DropdownMenuItem
+                                    key={themeOption}
+                                    onClick={() =>
+                                        handleThemeChange(themeOption)
+                                    }
+                                >
+                                    {themeOption === "light" && (
+                                        <Sun className="mr-2 h-4 w-4" />
+                                    )}
+                                    {themeOption === "dark" && (
+                                        <Moon className="mr-2 h-4 w-4" />
+                                    )}
+                                    {themeOption === "system" && (
+                                        <Laptop className="mr-2 h-4 w-4" />
+                                    )}
+                                    <span className="capitalize">
+                                        {themeOption}
+                                    </span>
+                                    {userTheme === themeOption && (
+                                        <span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
+                                            <span className="h-2 w-2 rounded-full bg-primary"></span>
+                                        </span>
+                                    )}
+                                </DropdownMenuItem>
+                            )
+                        )}
+                        <DropdownMenuSeparator />
+                        <DropdownMenuItem onClick={() => logout()}>
+                            <LogOut className="mr-2 h-4 w-4" />
+                            <span>Log out</span>
+                        </DropdownMenuItem>
+                    </DropdownMenuContent>
+                </DropdownMenu>
+                <span className="truncate max-w-full font-medium min-w-0 mr-1">
+                    {user.email}
+                </span>
+            </div>
+        </>
+    );
+}

+ 1 - 1
src/components/ui/card.tsx

@@ -50,7 +50,7 @@ const CardDescription = React.forwardRef<
 >(({ className, ...props }, ref) => (
     <p
         ref={ref}
-        className={cn("text-sm text-muted-foreground", className)}
+        className={cn("text-sm text-muted-foreground pt-1", className)}
         {...props}
     />
 ));