enable 2fa flow

This commit is contained in:
Milo Schwartz
2024-12-23 23:59:15 -05:00
parent b1afba191e
commit 9e50a580a5
33 changed files with 605 additions and 2084 deletions

View File

@@ -1,278 +0,0 @@
"use client";
import { createApiClient } from "@app/api";
import { Avatar, AvatarFallback } from "@app/components/ui/avatar";
import { Button } from "@app/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator
} from "@app/components/ui/command";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue
} from "@app/components/ui/select";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useToast } from "@app/hooks/useToast";
import { cn, formatAxiosError } from "@app/lib/utils";
import { ListOrgsResponse } from "@server/routers/org";
import {
Check,
ChevronsUpDown,
Laptop,
LogOut,
Moon,
Plus,
Sun,
User
} from "lucide-react";
import { useTheme } from "next-themes";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
type HeaderProps = {
name?: string;
email: string;
orgId: string;
orgs: ListOrgsResponse["orgs"];
};
export default function Header({ email, orgId, name, orgs }: HeaderProps) {
const { toast } = useToast();
const { setTheme, theme } = useTheme();
const [open, setOpen] = useState(false);
const [userTheme, setUserTheme] = useState<"light" | "dark" | "system">(
theme as "light" | "dark" | "system"
);
const router = useRouter();
const api = createApiClient(useEnvContext());
function getInitials() {
if (name) {
const [firstName, lastName] = name.split(" ");
return `${firstName[0]}${lastName[0]}`;
}
return 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 (
<>
<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">
{email}
</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem>
<User className="mr-2 h-4 w-4" />
<span>User Settings</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">
{name || email}
</span>
</div>
<div className="flex items-center">
<div className="hidden md:block">
<div className="flex items-center gap-4 mr-4">
<Link
href="/docs"
className="text-muted-foreground hover:text-foreground"
>
Documentation
</Link>
<Link
href="/support"
className="text-muted-foreground hover:text-foreground"
>
Support
</Link>
</div>
</div>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
size="lg"
role="combobox"
aria-expanded={open}
className="w-full md:w-[200px] h-12 px-3 py-4 bg-neutral hover:bg-neutral"
>
<div className="flex items-center justify-between w-full">
<div className="flex flex-col items-start">
<span className="font-bold text-sm">
Organization
</span>
<span className="text-sm text-muted-foreground">
{orgId
? orgs.find(
(org) =>
org.orgId === orgId
)?.name
: "Select organization..."}
</span>
</div>
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
</div>
</Button>
</PopoverTrigger>
<PopoverContent className="[100px] md:w-[180px] p-0">
<Command>
<CommandInput placeholder="Search..." />
<CommandEmpty>
No organizations found.
</CommandEmpty>
<CommandGroup heading="Create">
<CommandList>
<CommandItem
className="flex items-center cursor-pointer"
onSelect={(currentValue) => {
router.push("/setup");
}}
>
<Plus className="mr-2 h-4 w-4" />
New Organization
</CommandItem>
</CommandList>
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Organizations">
<CommandList>
{orgs.map((org) => (
<CommandItem
key={org.orgId}
onSelect={(currentValue) => {
router.push(
`/${org.orgId}/settings`
);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
orgId === org.orgId
? "opacity-100"
: "opacity-0"
)}
/>
{org.name}
</CommandItem>
))}
</CommandList>
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
</div>
</div>
</>
);
}

View File

@@ -1,62 +0,0 @@
"use client";
import React from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
interface TopbarNavProps extends React.HTMLAttributes<HTMLElement> {
items: {
href: string;
title: string;
icon: React.ReactNode;
}[];
disabled?: boolean;
orgId: string;
}
export function TopbarNav({
className,
items,
disabled = false,
orgId,
...props
}: TopbarNavProps) {
const pathname = usePathname();
return (
<nav
className={cn(
"flex overflow-x-auto space-x-4 lg:space-x-6",
disabled && "opacity-50 pointer-events-none",
className
)}
{...props}
>
{items.map((item) => (
<Link
key={item.href}
href={item.href.replace("{orgId}", orgId)}
className={cn(
"relative px-3 py-3 text-md",
pathname.startsWith(item.href.replace("{orgId}", orgId))
? "border-b-2 border-primary text-primary font-medium"
: "hover:text-primary text-muted-foreground font-medium",
"whitespace-nowrap",
disabled && "cursor-not-allowed"
)}
onClick={disabled ? (e) => e.preventDefault() : undefined}
tabIndex={disabled ? -1 : undefined}
aria-disabled={disabled}
>
<div className="flex items-center gap-2 relative px-2 py-0.5 rounded-md">
{item.icon && (
<div className="hidden md:block">{item.icon}</div>
)}
<span className="relative z-10">{item.title}</span>
</div>
</Link>
))}
</nav>
);
}

View File

@@ -1,7 +1,7 @@
import { Metadata } from "next";
import { TopbarNav } from "./components/TopbarNav";
import { TopbarNav } from "@app/components/TopbarNav";
import { Cog, Combine, Link, Settings, Users, Waypoints } from "lucide-react";
import Header from "./components/Header";
import { Header } from "@app/components/Header";
import { verifySession } from "@app/lib/auth/verifySession";
import { redirect } from "next/navigation";
import { internal } from "@app/api";
@@ -10,6 +10,7 @@ import { GetOrgResponse, ListOrgsResponse } from "@server/routers/org";
import { authCookieHeader } from "@app/api/cookies";
import { cache } from "react";
import { GetOrgUserResponse } from "@server/routers/user";
import UserProvider from "@app/providers/UserProvider";
export const dynamic = "force-dynamic";
@@ -99,17 +100,17 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
<div className="w-full border-b bg-neutral-100 dark:bg-neutral-800 select-none sm:px-0 px-3 fixed top-0 z-10">
<div className="container mx-auto flex flex-col content-between">
<div className="my-4">
<Header
email={user.email}
orgId={params.orgId}
orgs={orgs}
/>
<UserProvider user={user}>
<Header orgId={params.orgId} orgs={orgs} />
</UserProvider>
</div>
<TopbarNav items={topNavItems} orgId={params.orgId} />
</div>
</div>
<div className="container mx-auto sm:px-0 px-3 pt-[165px]">{children}</div>
<div className="container mx-auto sm:px-0 px-3 pt-[165px]">
{children}
</div>
</>
);
}

View File

@@ -226,7 +226,7 @@ export default function CreateShareLinkForm({
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>Create Sharable Link</CredenzaTitle>
<CredenzaTitle>Create Shareable Link</CredenzaTitle>
<CredenzaDescription>
Anyone with this link can access the resource
</CredenzaDescription>
@@ -436,10 +436,10 @@ export default function CreateShareLinkForm({
Expiration time is how long the
link will be usable and provide
access to the resource. After
this time, the link will expire
and no longer work, and users
who used this link will lose
access to the resource.
this time, the link will no
longer work, and users who used
this link will lose access to
the resource.
</p>
</div>
</form>

View File

@@ -195,14 +195,14 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
if (originalRow.online) {
return (
<span className="text-green-500 flex items-center space-x-2">
<Check className="w-4 h-4" />
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>Online</span>
</span>
);
} else {
return (
<span className="text-red-500 flex items-center space-x-2">
<X className="w-4 h-4" />
<span className="text-gray-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
<span>Offline</span>
</span>
);