Header.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305
  1. "use client";
  2. import { createApiClient } from "@app/api";
  3. import { Avatar, AvatarFallback } from "@app/components/ui/avatar";
  4. import { Button } from "@app/components/ui/button";
  5. import {
  6. Command,
  7. CommandEmpty,
  8. CommandGroup,
  9. CommandInput,
  10. CommandItem,
  11. CommandList,
  12. CommandSeparator
  13. } from "@app/components/ui/command";
  14. import {
  15. DropdownMenu,
  16. DropdownMenuContent,
  17. DropdownMenuItem,
  18. DropdownMenuLabel,
  19. DropdownMenuSeparator,
  20. DropdownMenuTrigger
  21. } from "@app/components/ui/dropdown-menu";
  22. import {
  23. Popover,
  24. PopoverContent,
  25. PopoverTrigger
  26. } from "@app/components/ui/popover";
  27. import { useEnvContext } from "@app/hooks/useEnvContext";
  28. import { useToast } from "@app/hooks/useToast";
  29. import { cn, formatAxiosError } from "@app/lib/utils";
  30. import { ListOrgsResponse } from "@server/routers/org";
  31. import {
  32. Check,
  33. ChevronsUpDown,
  34. Laptop,
  35. LogOut,
  36. Moon,
  37. Plus,
  38. Sun
  39. } from "lucide-react";
  40. import { useTheme } from "next-themes";
  41. import Link from "next/link";
  42. import { useRouter } from "next/navigation";
  43. import { useState } from "react";
  44. import Enable2FaForm from "./Enable2FaForm";
  45. import { useUserContext } from "@app/hooks/useUserContext";
  46. import Disable2FaForm from "./Disable2FaForm";
  47. type HeaderProps = {
  48. orgId?: string;
  49. orgs?: ListOrgsResponse["orgs"];
  50. };
  51. export function Header({ orgId, orgs }: HeaderProps) {
  52. const { toast } = useToast();
  53. const { setTheme, theme } = useTheme();
  54. const { user, updateUser } = useUserContext();
  55. const [open, setOpen] = useState(false);
  56. const [userTheme, setUserTheme] = useState<"light" | "dark" | "system">(
  57. theme as "light" | "dark" | "system"
  58. );
  59. const [openEnable2fa, setOpenEnable2fa] = useState(false);
  60. const [openDisable2fa, setOpenDisable2fa] = useState(false);
  61. const router = useRouter();
  62. const { env } = useEnvContext();
  63. const api = createApiClient({ env });
  64. function getInitials() {
  65. return user.email.substring(0, 2).toUpperCase();
  66. }
  67. function logout() {
  68. api.post("/auth/logout")
  69. .catch((e) => {
  70. console.error("Error logging out", e);
  71. toast({
  72. title: "Error logging out",
  73. description: formatAxiosError(e, "Error logging out")
  74. });
  75. })
  76. .then(() => {
  77. router.push("/auth/login");
  78. });
  79. }
  80. function handleThemeChange(theme: "light" | "dark" | "system") {
  81. setUserTheme(theme);
  82. setTheme(theme);
  83. }
  84. return (
  85. <>
  86. <Enable2FaForm open={openEnable2fa} setOpen={setOpenEnable2fa} />
  87. <Disable2FaForm open={openDisable2fa} setOpen={setOpenDisable2fa} />
  88. <div className="flex items-center justify-between">
  89. <div className="flex items-center gap-4">
  90. <DropdownMenu>
  91. <DropdownMenuTrigger asChild>
  92. <Button
  93. variant="outline"
  94. className="relative h-10 w-10 rounded-full"
  95. >
  96. <Avatar className="h-9 w-9">
  97. <AvatarFallback>
  98. {getInitials()}
  99. </AvatarFallback>
  100. </Avatar>
  101. </Button>
  102. </DropdownMenuTrigger>
  103. <DropdownMenuContent
  104. className="w-56"
  105. align="start"
  106. forceMount
  107. >
  108. <DropdownMenuLabel className="font-normal">
  109. <div className="flex flex-col space-y-1">
  110. <p className="text-sm font-medium leading-none">
  111. Signed in as
  112. </p>
  113. <p className="text-xs leading-none text-muted-foreground">
  114. {user.email}
  115. </p>
  116. </div>
  117. {user.serverAdmin && (
  118. <p className="text-xs leading-none text-muted-foreground mt-2">
  119. Server Admin
  120. </p>
  121. )}
  122. </DropdownMenuLabel>
  123. <DropdownMenuSeparator />
  124. {!user.twoFactorEnabled && (
  125. <DropdownMenuItem
  126. onClick={() => setOpenEnable2fa(true)}
  127. >
  128. <span>Enable Two-factor</span>
  129. </DropdownMenuItem>
  130. )}
  131. {user.twoFactorEnabled && (
  132. <DropdownMenuItem
  133. onClick={() => setOpenDisable2fa(true)}
  134. >
  135. <span>Disable Two-factor</span>
  136. </DropdownMenuItem>
  137. )}
  138. <DropdownMenuSeparator />
  139. <DropdownMenuLabel>Theme</DropdownMenuLabel>
  140. {(["light", "dark", "system"] as const).map(
  141. (themeOption) => (
  142. <DropdownMenuItem
  143. key={themeOption}
  144. onClick={() =>
  145. handleThemeChange(themeOption)
  146. }
  147. >
  148. {themeOption === "light" && (
  149. <Sun className="mr-2 h-4 w-4" />
  150. )}
  151. {themeOption === "dark" && (
  152. <Moon className="mr-2 h-4 w-4" />
  153. )}
  154. {themeOption === "system" && (
  155. <Laptop className="mr-2 h-4 w-4" />
  156. )}
  157. <span className="capitalize">
  158. {themeOption}
  159. </span>
  160. {userTheme === themeOption && (
  161. <span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
  162. <span className="h-2 w-2 rounded-full bg-primary"></span>
  163. </span>
  164. )}
  165. </DropdownMenuItem>
  166. )
  167. )}
  168. <DropdownMenuSeparator />
  169. <DropdownMenuItem onClick={() => logout()}>
  170. <LogOut className="mr-2 h-4 w-4" />
  171. <span>Log out</span>
  172. </DropdownMenuItem>
  173. </DropdownMenuContent>
  174. </DropdownMenu>
  175. <span className="truncate max-w-[150px] md:max-w-none font-medium">
  176. {user.email}
  177. </span>
  178. </div>
  179. <div className="flex items-center">
  180. <div className="hidden md:block">
  181. <div className="flex items-center gap-4 mr-4">
  182. <Link
  183. href="/docs"
  184. className="text-muted-foreground hover:text-foreground"
  185. >
  186. Documentation
  187. </Link>
  188. <Link
  189. href="/support"
  190. className="text-muted-foreground hover:text-foreground"
  191. >
  192. Support
  193. </Link>
  194. </div>
  195. </div>
  196. {orgs && (
  197. <Popover open={open} onOpenChange={setOpen}>
  198. <PopoverTrigger asChild>
  199. <Button
  200. variant="outline"
  201. size="lg"
  202. role="combobox"
  203. aria-expanded={open}
  204. className="w-full md:w-[200px] h-12 px-3 py-4 bg-neutral hover:bg-neutral"
  205. >
  206. <div className="flex items-center justify-between w-full">
  207. <div className="flex flex-col items-start">
  208. <span className="font-bold text-sm">
  209. Organization
  210. </span>
  211. <span className="text-sm text-muted-foreground">
  212. {orgId
  213. ? orgs?.find(
  214. (org) =>
  215. org.orgId ===
  216. orgId
  217. )?.name
  218. : "None selected"}
  219. </span>
  220. </div>
  221. <ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
  222. </div>
  223. </Button>
  224. </PopoverTrigger>
  225. <PopoverContent className="[100px] md:w-[180px] p-0">
  226. <Command>
  227. <CommandInput placeholder="Search..." />
  228. <CommandEmpty>
  229. No organizations found.
  230. </CommandEmpty>
  231. {(env.DISABLE_USER_CREATE_ORG === "false" ||
  232. user.serverAdmin) && (
  233. <>
  234. <CommandGroup heading="Create">
  235. <CommandList>
  236. <CommandItem
  237. onSelect={(
  238. currentValue
  239. ) => {
  240. router.push(
  241. "/setup"
  242. );
  243. }}
  244. >
  245. <Plus className="mr-2 h-4 w-4" />
  246. New Organization
  247. </CommandItem>
  248. </CommandList>
  249. </CommandGroup>
  250. <CommandSeparator />
  251. </>
  252. )}
  253. <CommandGroup heading="Organizations">
  254. <CommandList>
  255. {orgs.map((org) => (
  256. <CommandItem
  257. key={org.orgId}
  258. onSelect={(
  259. currentValue
  260. ) => {
  261. router.push(
  262. `/${org.orgId}/settings`
  263. );
  264. }}
  265. >
  266. <Check
  267. className={cn(
  268. "mr-2 h-4 w-4",
  269. orgId === org.orgId
  270. ? "opacity-100"
  271. : "opacity-0"
  272. )}
  273. />
  274. {org.name}
  275. </CommandItem>
  276. ))}
  277. </CommandList>
  278. </CommandGroup>
  279. </Command>
  280. </PopoverContent>
  281. </Popover>
  282. )}
  283. </div>
  284. </div>
  285. </>
  286. );
  287. }
  288. export default Header;