place holder landing pages

This commit is contained in:
Milo Schwartz 2024-12-26 19:33:56 -05:00
parent de9725f310
commit b78e7a324d
No known key found for this signature in database
22 changed files with 669 additions and 235 deletions

View file

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

View file

@ -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")
);
}
}

View file

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

View file

@ -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>
);
}

View file

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

View file

@ -1,10 +1,13 @@
import { internal } from "@app/api"; import ProfileIcon from "@app/components/ProfileIcon";
import { authCookieHeader } from "@app/api/cookies";
import { verifySession } from "@app/lib/auth/verifySession"; import { verifySession } from "@app/lib/auth/verifySession";
import { GetOrgUserResponse } from "@server/routers/user"; import UserProvider from "@app/providers/UserProvider";
import { AxiosResponse } from "axios";
import { redirect } from "next/navigation";
import { cache } from "react"; 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";
type OrgPageProps = { type OrgPageProps = {
params: Promise<{ orgId: string }>; params: Promise<{ orgId: string }>;
@ -14,9 +17,55 @@ export default async function OrgPage(props: OrgPageProps) {
const params = await props.params; const params = await props.params;
const orgId = params.orgId; 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 ( 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>
</> </>
); );
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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>
);
}

View file

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

View file

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

View file

@ -1,5 +1,6 @@
import { internal } from "@app/api"; import { internal } from "@app/api";
import { authCookieHeader } from "@app/api/cookies"; import { authCookieHeader } from "@app/api/cookies";
import ProfileIcon from "@app/components/ProfileIcon";
import { verifySession } from "@app/lib/auth/verifySession"; import { verifySession } from "@app/lib/auth/verifySession";
import UserProvider from "@app/providers/UserProvider"; import UserProvider from "@app/providers/UserProvider";
import { ListOrgsResponse } from "@server/routers/org"; import { ListOrgsResponse } from "@server/routers/org";
@ -8,11 +9,15 @@ import { ArrowUpRight } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { cache } from "react"; import { cache } from "react";
import OrganizationLanding from "./components/OrganizationLanding";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default async function Page(props: { 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 const params = await props.searchParams; // this is needed to prevent static optimization
@ -42,39 +47,42 @@ export default async function Page(props: {
try { try {
const res = await internal.get<AxiosResponse<ListOrgsResponse>>( const res = await internal.get<AxiosResponse<ListOrgsResponse>>(
`/orgs`, `/orgs`,
await authCookieHeader(), await authCookieHeader()
); );
if (res && res.data.data.orgs) { if (res && res.data.data.orgs) {
orgs = res.data.data.orgs; orgs = res.data.data.orgs;
} }
} catch (e) { } catch (e) {}
console.error(e);
}
if (!orgs.length) { if (!orgs.length) {
redirect("/setup"); if (
process.env.DISABLE_USER_CREATE_ORG === "false" ||
user.serverAdmin
) {
redirect("/setup");
}
} }
return ( return (
<> <>
<UserProvider user={user}> <div className="p-3">
<p>Logged in as {user.email}</p> {user && (
</UserProvider> <UserProvider user={user}>
<div>
<div className="mt-4"> <ProfileIcon />
{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> </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> </div>
</> </>
); );

View file

@ -1,4 +1,6 @@
import ProfileIcon from "@app/components/ProfileIcon";
import { verifySession } from "@app/lib/auth/verifySession"; import { verifySession } from "@app/lib/auth/verifySession";
import UserProvider from "@app/providers/UserProvider";
import { Metadata } from "next"; import { Metadata } from "next";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { cache } from "react"; import { cache } from "react";
@ -29,6 +31,20 @@ export default async function SetupLayout({
} }
return ( 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>
</>
); );
} }

View file

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

View file

@ -1,7 +1,5 @@
"use client"; "use client";
import { createApiClient } from "@app/api";
import { Avatar, AvatarFallback } from "@app/components/ui/avatar";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import { import {
Command, Command,
@ -12,39 +10,20 @@ import {
CommandList, CommandList,
CommandSeparator CommandSeparator
} from "@app/components/ui/command"; } from "@app/components/ui/command";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import { import {
Popover, Popover,
PopoverContent, PopoverContent,
PopoverTrigger PopoverTrigger
} from "@app/components/ui/popover"; } from "@app/components/ui/popover";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { useToast } from "@app/hooks/useToast"; import { cn } from "@app/lib/utils";
import { cn, formatAxiosError } from "@app/lib/utils";
import { ListOrgsResponse } from "@server/routers/org"; import { ListOrgsResponse } from "@server/routers/org";
import { import { Check, ChevronsUpDown, Plus } from "lucide-react";
Check,
ChevronsUpDown,
Laptop,
LogOut,
Moon,
Plus,
Sun
} from "lucide-react";
import { useTheme } from "next-themes";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import Enable2FaForm from "./Enable2FaForm";
import { useUserContext } from "@app/hooks/useUserContext"; import { useUserContext } from "@app/hooks/useUserContext";
import Disable2FaForm from "./Disable2FaForm"; import ProfileIcon from "./ProfileIcon";
type HeaderProps = { type HeaderProps = {
orgId?: string; orgId?: string;
@ -52,144 +31,18 @@ type HeaderProps = {
}; };
export function Header({ orgId, orgs }: HeaderProps) { export function Header({ orgId, orgs }: HeaderProps) {
const { toast } = useToast();
const { setTheme, theme } = useTheme();
const { user, updateUser } = useUserContext(); const { user, updateUser } = useUserContext();
const [open, setOpen] = useState(false); 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 router = useRouter();
const { env } = useEnvContext(); 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 ( return (
<> <>
<Enable2FaForm open={openEnable2fa} setOpen={setOpenEnable2fa} />
<Disable2FaForm open={openDisable2fa} setOpen={setOpenDisable2fa} />
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-4"> <ProfileIcon />
<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>
<div className="flex items-center"> <div className="flex items-center">
<div className="hidden md:block"> <div className="hidden md:block">

View file

@ -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>
</>
);
}

View file

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