Quellcode durchsuchen

enable 2fa flow

Milo Schwartz vor 6 Monaten
Ursprung
Commit
9e50a580a5
33 geänderte Dateien mit 605 neuen und 2084 gelöschten Zeilen
  1. 2 1
      package.json
  2. 9 0
      server/routers/auth/verifyTotp.ts
  3. 3 6
      src/app/[orgId]/layout.tsx
  4. 9 8
      src/app/[orgId]/settings/layout.tsx
  5. 5 5
      src/app/[orgId]/settings/share-links/components/CreateShareLinkForm.tsx
  6. 3 3
      src/app/[orgId]/settings/sites/components/SitesTable.tsx
  7. 0 176
      src/app/profile/account/account-form.tsx
  8. 0 18
      src/app/profile/account/page.tsx
  9. 0 164
      src/app/profile/appearance/appearance-form.tsx
  10. 0 18
      src/app/profile/appearance/page.tsx
  11. 0 132
      src/app/profile/display/display-form.tsx
  12. 0 17
      src/app/profile/display/page.tsx
  13. 36 0
      src/app/profile/general/layout_.tsx
  14. 14 0
      src/app/profile/general/page_.tsx
  15. 0 76
      src/app/profile/layout.tsx
  16. 74 0
      src/app/profile/layout_.tsx
  17. 0 222
      src/app/profile/notifications/notifications-form.tsx
  18. 0 17
      src/app/profile/notifications/page.tsx
  19. 0 17
      src/app/profile/page.tsx
  20. 5 0
      src/app/profile/page_.tsx
  21. 0 192
      src/app/profile/profile-form.tsx
  22. 291 0
      src/components/Enable2FaForm.tsx
  23. 101 95
      src/components/Header.tsx
  24. 7 1
      src/components/LoginForm.tsx
  25. 3 3
      src/components/TopbarNav.tsx
  26. 0 176
      src/components/account-form.tsx
  27. 0 179
      src/components/appearance-form.tsx
  28. 0 132
      src/components/display-form.tsx
  29. 0 222
      src/components/notifications-form.tsx
  30. 0 192
      src/components/profile-form.tsx
  31. 8 1
      src/contexts/userContext.ts
  32. 7 4
      src/hooks/useUserContext.ts
  33. 28 7
      src/providers/UserProvider.tsx

+ 2 - 1
package.json

@@ -60,6 +60,7 @@
         "node-fetch": "3.3.2",
         "nodemailer": "6.9.15",
         "oslo": "1.2.1",
+        "qrcode.react": "4.2.0",
         "react": "19.0.0-rc.1",
         "react-dom": "19.0.0-rc.1",
         "react-hook-form": "7.53.0",
@@ -74,7 +75,6 @@
         "zod-validation-error": "3.4.0"
     },
     "devDependencies": {
-        "react-email": "3.0.2",
         "@dotenvx/dotenvx": "1.14.2",
         "@esbuild-plugins/tsconfig-paths": "0.1.2",
         "@types/better-sqlite3": "7.6.11",
@@ -92,6 +92,7 @@
         "esbuild": "0.20.1",
         "esbuild-node-externals": "1.13.0",
         "postcss": "^8",
+        "react-email": "3.0.2",
         "tailwindcss": "^3.4.1",
         "tsc-alias": "1.8.10",
         "tsx": "4.19.1",

+ 9 - 0
server/routers/auth/verifyTotp.ts

@@ -92,6 +92,15 @@ export async function verifyTotp(
 
         // TODO: send email to user confirming two-factor authentication is enabled
 
+        if (!valid) {
+            return next(
+                createHttpError(
+                    HttpCode.BAD_REQUEST,
+                    "Invalid two-factor authentication code"
+                )
+            );
+        }
+
         return response<VerifyTotpResponse>(res, {
             data: {
                 valid,

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

@@ -30,8 +30,8 @@ export default async function OrgLayout(props: {
         const getOrgUser = cache(() =>
             internal.get<AxiosResponse<GetOrgUserResponse>>(
                 `/org/${orgId}/user/${user.userId}`,
-                cookie,
-            ),
+                cookie
+            )
         );
         const orgUser = await getOrgUser();
     } catch {
@@ -40,10 +40,7 @@ export default async function OrgLayout(props: {
 
     try {
         const getOrg = cache(() =>
-            internal.get<AxiosResponse<GetOrgResponse>>(
-                `/org/${orgId}`,
-                cookie,
-            ),
+            internal.get<AxiosResponse<GetOrgResponse>>(`/org/${orgId}`, cookie)
         );
         await getOrg();
     } catch {

+ 9 - 8
src/app/[orgId]/settings/layout.tsx

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

+ 5 - 5
src/app/[orgId]/settings/share-links/components/CreateShareLinkForm.tsx

@@ -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>

+ 3 - 3
src/app/[orgId]/settings/sites/components/SitesTable.tsx

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

+ 0 - 176
src/app/profile/account/account-form.tsx

@@ -1,176 +0,0 @@
-"use client"
-
-import { zodResolver } from "@hookform/resolvers/zod"
-import { CalendarIcon, CaretSortIcon, CheckIcon } from "@radix-ui/react-icons"
-import { useForm } from "react-hook-form"
-import { z } from "zod"
-
-import { cn } from "@/lib/utils"
-import { toast } from "@/hooks/useToast"
-import { Button } from "@/components/ui/button"
-import {
-  Command,
-  CommandEmpty,
-  CommandGroup,
-  CommandInput,
-  CommandItem,
-  CommandList,
-} from "@/components/ui/command"
-import {
-  Form,
-  FormControl,
-  FormDescription,
-  FormField,
-  FormItem,
-  FormLabel,
-  FormMessage,
-} from "@/components/ui/form"
-import { Input } from "@/components/ui/input"
-import {
-  Popover,
-  PopoverContent,
-  PopoverTrigger,
-} from "@/components/ui/popover"
-
-const languages = [
-  { label: "English", value: "en" },
-  { label: "French", value: "fr" },
-  { label: "German", value: "de" },
-  { label: "Spanish", value: "es" },
-  { label: "Portuguese", value: "pt" },
-  { label: "Russian", value: "ru" },
-  { label: "Japanese", value: "ja" },
-  { label: "Korean", value: "ko" },
-  { label: "Chinese", value: "zh" },
-] as const
-
-const accountFormSchema = z.object({
-  name: z
-    .string()
-    .min(2, {
-      message: "Name must be at least 2 characters.",
-    })
-    .max(30, {
-      message: "Name must not be longer than 30 characters.",
-    }),
-  dob: z.date({
-    required_error: "A date of birth is required.",
-  }),
-  language: z.string({
-    required_error: "Please select a language.",
-  }),
-})
-
-type AccountFormValues = z.infer<typeof accountFormSchema>
-
-// This can come from your database or API.
-const defaultValues: Partial<AccountFormValues> = {
-  // name: "Your name",
-  // dob: new Date("2023-01-23"),
-}
-
-export function AccountForm() {
-  const form = useForm<AccountFormValues>({
-    resolver: zodResolver(accountFormSchema),
-    defaultValues,
-  })
-
-  function onSubmit(data: AccountFormValues) {
-    toast({
-      title: "You submitted the following values:",
-      description: (
-        <pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
-          <code className="text-white">{JSON.stringify(data, null, 2)}</code>
-        </pre>
-      ),
-    })
-  }
-
-  return (
-    <Form {...form}>
-      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
-        <FormField
-          control={form.control}
-          name="name"
-          render={({ field }) => (
-            <FormItem>
-              <FormLabel>Name</FormLabel>
-              <FormControl>
-                <Input placeholder="Your name" {...field} />
-              </FormControl>
-              <FormDescription>
-                This is the name that will be displayed on your profile and in
-                emails.
-              </FormDescription>
-              <FormMessage />
-            </FormItem>
-          )}
-        />
-        <FormField
-          control={form.control}
-          name="language"
-          render={({ field }) => (
-            <FormItem className="flex flex-col">
-              <FormLabel>Language</FormLabel>
-              <Popover>
-                <PopoverTrigger asChild>
-                  <FormControl>
-                    <Button
-                      variant="outline"
-                      role="combobox"
-                      className={cn(
-                        "w-[200px] justify-between",
-                        !field.value && "text-muted-foreground"
-                      )}
-                    >
-                      {field.value
-                        ? languages.find(
-                            (language) => language.value === field.value
-                          )?.label
-                        : "Select language"}
-                      <CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
-                    </Button>
-                  </FormControl>
-                </PopoverTrigger>
-                <PopoverContent className="w-[200px] p-0">
-                  <Command>
-                    <CommandInput placeholder="Search language..." />
-                    <CommandList>
-                      <CommandEmpty>No language found.</CommandEmpty>
-                      <CommandGroup>
-                        {languages.map((language) => (
-                          <CommandItem
-                            value={language.label}
-                            key={language.value}
-                            onSelect={() => {
-                              form.setValue("language", language.value)
-                            }}
-                          >
-                            <CheckIcon
-                              className={cn(
-                                "mr-2 h-4 w-4",
-                                language.value === field.value
-                                  ? "opacity-100"
-                                  : "opacity-0"
-                              )}
-                            />
-                            {language.label}
-                          </CommandItem>
-                        ))}
-                      </CommandGroup>
-                    </CommandList>
-                  </Command>
-                </PopoverContent>
-              </Popover>
-              <FormDescription>
-                This is the language that will be used in the dashboard.
-              </FormDescription>
-              <FormMessage />
-            </FormItem>
-          )}
-        />
-        <Button type="submit">Update account</Button>
-      </form>
-    </Form>
-  )
-}

+ 0 - 18
src/app/profile/account/page.tsx

@@ -1,18 +0,0 @@
-import { Separator } from "@/components/ui/separator"
-import { AccountForm } from "./account-form"
-
-export default function SettingsAccountPage() {
-  return (
-    <div className="space-y-8">
-      <div>
-        <h3 className="text-lg font-medium">Account</h3>
-        <p className="text-sm text-muted-foreground">
-          Update your account settings. Set your preferred language and
-          timezone.
-        </p>
-      </div>
-      <Separator />
-      <AccountForm />
-    </div>
-  )
-}

+ 0 - 164
src/app/profile/appearance/appearance-form.tsx

@@ -1,164 +0,0 @@
-"use client"
-
-import { zodResolver } from "@hookform/resolvers/zod"
-import { ChevronDownIcon } from "@radix-ui/react-icons"
-import { useForm } from "react-hook-form"
-import { z } from "zod"
-
-import { cn } from "@/lib/utils"
-import { toast } from "@/hooks/useToast"
-import { Button, buttonVariants } from "@/components/ui/button"
-import {
-  Form,
-  FormControl,
-  FormDescription,
-  FormField,
-  FormItem,
-  FormLabel,
-  FormMessage,
-} from "@/components/ui/form"
-import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
-
-const appearanceFormSchema = z.object({
-  theme: z.enum(["light", "dark"], {
-    required_error: "Please select a theme.",
-  }),
-  font: z.enum(["inter", "manrope", "system"], {
-    invalid_type_error: "Select a font",
-    required_error: "Please select a font.",
-  }),
-})
-
-type AppearanceFormValues = z.infer<typeof appearanceFormSchema>
-
-// This can come from your database or API.
-const defaultValues: Partial<AppearanceFormValues> = {
-  theme: "light",
-}
-
-export function AppearanceForm() {
-  const form = useForm<AppearanceFormValues>({
-    resolver: zodResolver(appearanceFormSchema),
-    defaultValues,
-  })
-
-  function onSubmit(data: AppearanceFormValues) {
-    toast({
-      title: "You submitted the following values:",
-      description: (
-        <pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
-          <code className="text-white">{JSON.stringify(data, null, 2)}</code>
-        </pre>
-      ),
-    })
-  }
-
-  return (
-    <Form {...form}>
-      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
-        <FormField
-          control={form.control}
-          name="font"
-          render={({ field }) => (
-            <FormItem>
-              <FormLabel>Font</FormLabel>
-              <div className="relative w-max">
-                <FormControl>
-                  <select
-                    className={cn(
-                      buttonVariants({ variant: "outline" }),
-                      "w-[200px] appearance-none font-normal"
-                    )}
-                    {...field}
-                  >
-                    <option value="inter">Inter</option>
-                    <option value="manrope">Manrope</option>
-                    <option value="system">System</option>
-                  </select>
-                </FormControl>
-                <ChevronDownIcon className="absolute right-3 top-2.5 h-4 w-4 opacity-50" />
-              </div>
-              <FormDescription>
-                Set the font you want to use in the dashboard.
-              </FormDescription>
-              <FormMessage />
-            </FormItem>
-          )}
-        />
-        <FormField
-          control={form.control}
-          name="theme"
-          render={({ field }) => (
-            <FormItem className="space-y-1">
-              <FormLabel>Theme</FormLabel>
-              <FormDescription>
-                Select the theme for the dashboard.
-              </FormDescription>
-              <FormMessage />
-              <RadioGroup
-                onValueChange={field.onChange}
-                defaultValue={field.value}
-                className="grid max-w-md grid-cols-2 gap-8 pt-2"
-              >
-                <FormItem>
-                  <FormLabel className="[&:has([data-state=checked])>div]:border-primary">
-                    <FormControl>
-                      <RadioGroupItem value="light" className="sr-only" />
-                    </FormControl>
-                    <div className="items-center rounded-md border-2 border-muted p-1 hover:border-accent">
-                      <div className="space-y-2 rounded-sm bg-[#ecedef] p-2">
-                        <div className="space-y-2 rounded-md bg-white p-2 shadow-sm">
-                          <div className="h-2 w-[80px] rounded-lg bg-[#ecedef]" />
-                          <div className="h-2 w-[100px] rounded-lg bg-[#ecedef]" />
-                        </div>
-                        <div className="flex items-center space-x-2 rounded-md bg-white p-2 shadow-sm">
-                          <div className="h-4 w-4 rounded-full bg-[#ecedef]" />
-                          <div className="h-2 w-[100px] rounded-lg bg-[#ecedef]" />
-                        </div>
-                        <div className="flex items-center space-x-2 rounded-md bg-white p-2 shadow-sm">
-                          <div className="h-4 w-4 rounded-full bg-[#ecedef]" />
-                          <div className="h-2 w-[100px] rounded-lg bg-[#ecedef]" />
-                        </div>
-                      </div>
-                    </div>
-                    <span className="block w-full p-2 text-center font-normal">
-                      Light
-                    </span>
-                  </FormLabel>
-                </FormItem>
-                <FormItem>
-                  <FormLabel className="[&:has([data-state=checked])>div]:border-primary">
-                    <FormControl>
-                      <RadioGroupItem value="dark" className="sr-only" />
-                    </FormControl>
-                    <div className="items-center rounded-md border-2 border-muted bg-popover p-1 hover:bg-accent hover:text-accent-foreground">
-                      <div className="space-y-2 rounded-sm bg-slate-950 p-2">
-                        <div className="space-y-2 rounded-md bg-slate-800 p-2 shadow-sm">
-                          <div className="h-2 w-[80px] rounded-lg bg-slate-400" />
-                          <div className="h-2 w-[100px] rounded-lg bg-slate-400" />
-                        </div>
-                        <div className="flex items-center space-x-2 rounded-md bg-slate-800 p-2 shadow-sm">
-                          <div className="h-4 w-4 rounded-full bg-slate-400" />
-                          <div className="h-2 w-[100px] rounded-lg bg-slate-400" />
-                        </div>
-                        <div className="flex items-center space-x-2 rounded-md bg-slate-800 p-2 shadow-sm">
-                          <div className="h-4 w-4 rounded-full bg-slate-400" />
-                          <div className="h-2 w-[100px] rounded-lg bg-slate-400" />
-                        </div>
-                      </div>
-                    </div>
-                    <span className="block w-full p-2 text-center font-normal">
-                      Dark
-                    </span>
-                  </FormLabel>
-                </FormItem>
-              </RadioGroup>
-            </FormItem>
-          )}
-        />
-
-        <Button type="submit">Update preferences</Button>
-      </form>
-    </Form>
-  )
-}

+ 0 - 18
src/app/profile/appearance/page.tsx

@@ -1,18 +0,0 @@
-import { Separator } from "@/components/ui/separator"
-import { AppearanceForm } from "./appearance-form"
-
-export default function SettingsAppearancePage() {
-  return (
-    <div className="space-y-8">
-      <div>
-        <h3 className="text-lg font-medium">Appearance</h3>
-        <p className="text-sm text-muted-foreground">
-          Customize the appearance of the app. Automatically switch between day
-          and night themes.
-        </p>
-      </div>
-      <Separator />
-      <AppearanceForm />
-    </div>
-  )
-}

+ 0 - 132
src/app/profile/display/display-form.tsx

@@ -1,132 +0,0 @@
-"use client"
-
-import { zodResolver } from "@hookform/resolvers/zod"
-import { useForm } from "react-hook-form"
-import { z } from "zod"
-
-import { toast } from "@/hooks/useToast"
-import { Button } from "@/components/ui/button"
-import { Checkbox } from "@/components/ui/checkbox"
-import {
-  Form,
-  FormControl,
-  FormDescription,
-  FormField,
-  FormItem,
-  FormLabel,
-  FormMessage,
-} from "@/components/ui/form"
-
-const items = [
-  {
-    id: "recents",
-    label: "Recents",
-  },
-  {
-    id: "home",
-    label: "Home",
-  },
-  {
-    id: "applications",
-    label: "Applications",
-  },
-  {
-    id: "desktop",
-    label: "Desktop",
-  },
-  {
-    id: "downloads",
-    label: "Downloads",
-  },
-  {
-    id: "documents",
-    label: "Documents",
-  },
-] as const
-
-const displayFormSchema = z.object({
-  items: z.array(z.string()).refine((value) => value.some((item) => item), {
-    message: "You have to select at least one item.",
-  }),
-})
-
-type DisplayFormValues = z.infer<typeof displayFormSchema>
-
-// This can come from your database or API.
-const defaultValues: Partial<DisplayFormValues> = {
-  items: ["recents", "home"],
-}
-
-export function DisplayForm() {
-  const form = useForm<DisplayFormValues>({
-    resolver: zodResolver(displayFormSchema),
-    defaultValues,
-  })
-
-  function onSubmit(data: DisplayFormValues) {
-    toast({
-      title: "You submitted the following values:",
-      description: (
-        <pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
-          <code className="text-white">{JSON.stringify(data, null, 2)}</code>
-        </pre>
-      ),
-    })
-  }
-
-  return (
-    <Form {...form}>
-      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
-        <FormField
-          control={form.control}
-          name="items"
-          render={() => (
-            <FormItem>
-              <div className="mb-4">
-                <FormLabel className="text-base">Sidebar</FormLabel>
-                <FormDescription>
-                  Select the items you want to display in the sidebar.
-                </FormDescription>
-              </div>
-              {items.map((item) => (
-                <FormField
-                  key={item.id}
-                  control={form.control}
-                  name="items"
-                  render={({ field }) => {
-                    return (
-                      <FormItem
-                        key={item.id}
-                        className="flex flex-row items-start space-x-3 space-y-0"
-                      >
-                        <FormControl>
-                          <Checkbox
-                            checked={field.value?.includes(item.id)}
-                            onCheckedChange={(checked) => {
-                              return checked
-                                ? field.onChange([...field.value, item.id])
-                                : field.onChange(
-                                    field.value?.filter(
-                                      (value) => value !== item.id
-                                    )
-                                  )
-                            }}
-                          />
-                        </FormControl>
-                        <FormLabel className="font-normal">
-                          {item.label}
-                        </FormLabel>
-                      </FormItem>
-                    )
-                  }}
-                />
-              ))}
-              <FormMessage />
-            </FormItem>
-          )}
-        />
-        <Button type="submit">Update display</Button>
-      </form>
-    </Form>
-  )
-}

+ 0 - 17
src/app/profile/display/page.tsx

@@ -1,17 +0,0 @@
-import { Separator } from "@/components/ui/separator"
-import { DisplayForm } from "./display-form"
-
-export default function SettingsDisplayPage() {
-  return (
-    <div className="space-y-8">
-      <div>
-        <h3 className="text-lg font-medium">Display</h3>
-        <p className="text-sm text-muted-foreground">
-          Turn items on or off to control what&apos;s displayed in the app.
-        </p>
-      </div>
-      <Separator />
-      <DisplayForm />
-    </div>
-  )
-}

+ 36 - 0
src/app/profile/general/layout_.tsx

@@ -0,0 +1,36 @@
+import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
+import { SidebarSettings } from "@app/components/SidebarSettings";
+import { verifySession } from "@app/lib/auth/verifySession";
+import UserProvider from "@app/providers/UserProvider";
+import { redirect } from "next/navigation";
+import { cache } from "react";
+
+type ProfileGeneralProps = {
+    children: React.ReactNode;
+};
+
+export default async function GeneralSettingsPage({
+    children
+}: ProfileGeneralProps) {
+    const getUser = cache(verifySession);
+    const user = await getUser();
+
+    if (!user) {
+        redirect(`/?redirect=/profile/general`);
+    }
+
+    const sidebarNavItems = [
+        {
+            title: "Authentication",
+            href: `/{orgId}/settings/general`
+        }
+    ];
+
+    return (
+        <>
+            <UserProvider user={user}>
+                {children}
+            </UserProvider>
+        </>
+    );
+}

+ 14 - 0
src/app/profile/general/page_.tsx

@@ -0,0 +1,14 @@
+"use client";
+
+import { useState } from "react";
+import Enable2FaForm from "./components/Enable2FaForm";
+
+export default function ProfileGeneralPage() {
+    const [open, setOpen] = useState(true);
+
+    return (
+        <>
+            <Enable2FaForm open={open} setOpen={setOpen} />
+        </>
+    );
+}

+ 0 - 76
src/app/profile/layout.tsx

@@ -1,76 +0,0 @@
-import { Metadata } from "next"
-import Image from "next/image"
-
-import { Separator } from "@/components/ui/separator"
-import { SidebarNav } from "@/components/sidebar-nav"
-import Header from "../[orgId]/settings/components/Header"
-
-export const metadata: Metadata = {
-  title: "Forms",
-  description: "Advanced form example using react-hook-form and Zod.",
-}
-
-const sidebarNavItems = [
-  {
-    title: "Profile",
-    href: "/configuration",
-  },
-  {
-    title: "Account",
-    href: "/configuration/account",
-  },
-  {
-    title: "Appearance",
-    href: "/configuration/appearance",
-  },
-  {
-    title: "Notifications",
-    href: "/configuration/notifications",
-  },
-  {
-    title: "Display",
-    href: "/configuration/display",
-  },
-]
-
-interface SettingsLayoutProps {
-  children: React.ReactNode
-}
-
-export default function SettingsLayout({ children }: SettingsLayoutProps) {
-  return (
-    <>
-      <div className="md:hidden">
-        <Image
-          src="/configuration/forms-light.png"
-          width={1280}
-          height={791}
-          alt="Forms"
-          className="block dark:hidden"
-        />
-        <Image
-          src="/configuration/forms-dark.png"
-          width={1280}
-          height={791}
-          alt="Forms"
-          className="hidden dark:block"
-        />
-      </div>
-      <div className="hidden space-y-8 p-10 pb-16 md:block">
-        <div className="space-y-0.5">
-          <h2 className="text-2xl font-bold tracking-tight">Settings</h2>
-          <p className="text-muted-foreground">
-            Manage your account settings and set e-mail preferences.
-          </p>
-        </div>
-        <Separator className="my-6" />
-        <div className="flex flex-col space-y-4 lg:flex-row lg:space-x-12 lg:space-y-0">
-          <aside className="-mx-4 lg:w-1/5">
-            <SidebarNav items={sidebarNavItems} />
-          </aside>
-          <div className="flex-1 lg:max-w-2xl">{children}</div>
-        </div>
-      </div>
-    </>
-  )
-}

+ 74 - 0
src/app/profile/layout_.tsx

@@ -0,0 +1,74 @@
+import { Metadata } from "next";
+import { verifySession } from "@app/lib/auth/verifySession";
+import { redirect } from "next/navigation";
+import { cache } from "react";
+import Header from "@app/components/Header";
+import { internal } from "@app/api";
+import { AxiosResponse } from "axios";
+import { ListOrgsResponse } from "@server/routers/org";
+import { authCookieHeader } from "@app/api/cookies";
+import { TopbarNav } from "@app/components/TopbarNav";
+import { Settings } from "lucide-react";
+
+export const dynamic = "force-dynamic";
+
+export const metadata: Metadata = {
+    title: `User Settings - Pangolin`,
+    description: ""
+};
+
+const topNavItems = [
+    {
+        title: "User Settings",
+        href: "/profile/general",
+        icon: <Settings className="h-4 w-4" />
+    }
+];
+
+interface SettingsLayoutProps {
+    children: React.ReactNode;
+    params: Promise<{}>;
+}
+
+export default async function SettingsLayout(props: SettingsLayoutProps) {
+    const { children } = props;
+
+    const getUser = cache(verifySession);
+    const user = await getUser();
+
+    if (!user) {
+        redirect(`/`);
+    }
+
+    const cookie = await authCookieHeader();
+
+    let orgs: ListOrgsResponse["orgs"] = [];
+    try {
+        const getOrgs = cache(() =>
+            internal.get<AxiosResponse<ListOrgsResponse>>(`/orgs`, cookie)
+        );
+        const res = await getOrgs();
+        if (res && res.data.data.orgs) {
+            orgs = res.data.data.orgs;
+        }
+    } catch (e) {
+        console.error("Error fetching orgs", e);
+    }
+
+    return (
+        <>
+            <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} orgs={orgs} />
+                    </div>
+                    <TopbarNav items={topNavItems} />
+                </div>
+            </div>
+
+            <div className="container mx-auto sm:px-0 px-3 pt-[165px]">
+                {children}
+            </div>
+        </>
+    );
+}

+ 0 - 222
src/app/profile/notifications/notifications-form.tsx

@@ -1,222 +0,0 @@
-"use client"
-
-import Link from "next/link"
-import { zodResolver } from "@hookform/resolvers/zod"
-import { useForm } from "react-hook-form"
-import { z } from "zod"
-
-import { toast } from "@/hooks/useToast"
-import { Button } from "@/components/ui/button"
-import { Checkbox } from "@/components/ui/checkbox"
-import {
-  Form,
-  FormControl,
-  FormDescription,
-  FormField,
-  FormItem,
-  FormLabel,
-  FormMessage,
-} from "@/components/ui/form"
-import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
-import { Switch } from "@/components/ui/switch"
-
-const notificationsFormSchema = z.object({
-  type: z.enum(["all", "mentions", "none"], {
-    required_error: "You need to select a notification type.",
-  }),
-  mobile: z.boolean().default(false).optional(),
-  communication_emails: z.boolean().default(false).optional(),
-  social_emails: z.boolean().default(false).optional(),
-  marketing_emails: z.boolean().default(false).optional(),
-  security_emails: z.boolean(),
-})
-
-type NotificationsFormValues = z.infer<typeof notificationsFormSchema>
-
-// This can come from your database or API.
-const defaultValues: Partial<NotificationsFormValues> = {
-  communication_emails: false,
-  marketing_emails: false,
-  social_emails: true,
-  security_emails: true,
-}
-
-export function NotificationsForm() {
-  const form = useForm<NotificationsFormValues>({
-    resolver: zodResolver(notificationsFormSchema),
-    defaultValues,
-  })
-
-  function onSubmit(data: NotificationsFormValues) {
-    toast({
-      title: "You submitted the following values:",
-      description: (
-        <pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
-          <code className="text-white">{JSON.stringify(data, null, 2)}</code>
-        </pre>
-      ),
-    })
-  }
-
-  return (
-    <Form {...form}>
-      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
-        <FormField
-          control={form.control}
-          name="type"
-          render={({ field }) => (
-            <FormItem className="space-y-3">
-              <FormLabel>Notify me about...</FormLabel>
-              <FormControl>
-                <RadioGroup
-                  onValueChange={field.onChange}
-                  defaultValue={field.value}
-                  className="flex flex-col space-y-1"
-                >
-                  <FormItem className="flex items-center space-x-3 space-y-0">
-                    <FormControl>
-                      <RadioGroupItem value="all" />
-                    </FormControl>
-                    <FormLabel className="font-normal">
-                      All new messages
-                    </FormLabel>
-                  </FormItem>
-                  <FormItem className="flex items-center space-x-3 space-y-0">
-                    <FormControl>
-                      <RadioGroupItem value="mentions" />
-                    </FormControl>
-                    <FormLabel className="font-normal">
-                      Direct messages and mentions
-                    </FormLabel>
-                  </FormItem>
-                  <FormItem className="flex items-center space-x-3 space-y-0">
-                    <FormControl>
-                      <RadioGroupItem value="none" />
-                    </FormControl>
-                    <FormLabel className="font-normal">Nothing</FormLabel>
-                  </FormItem>
-                </RadioGroup>
-              </FormControl>
-              <FormMessage />
-            </FormItem>
-          )}
-        />
-        <div>
-          <h3 className="mb-4 text-lg font-medium">Email Notifications</h3>
-          <div className="space-y-4">
-            <FormField
-              control={form.control}
-              name="communication_emails"
-              render={({ field }) => (
-                <FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
-                  <div className="space-y-0.5">
-                    <FormLabel className="text-base">
-                      Communication emails
-                    </FormLabel>
-                    <FormDescription>
-                      Receive emails about your account activity.
-                    </FormDescription>
-                  </div>
-                  <FormControl>
-                    <Switch
-                      checked={field.value}
-                      onCheckedChange={field.onChange}
-                    />
-                  </FormControl>
-                </FormItem>
-              )}
-            />
-            <FormField
-              control={form.control}
-              name="marketing_emails"
-              render={({ field }) => (
-                <FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
-                  <div className="space-y-0.5">
-                    <FormLabel className="text-base">
-                      Marketing emails
-                    </FormLabel>
-                    <FormDescription>
-                      Receive emails about new products, features, and more.
-                    </FormDescription>
-                  </div>
-                  <FormControl>
-                    <Switch
-                      checked={field.value}
-                      onCheckedChange={field.onChange}
-                    />
-                  </FormControl>
-                </FormItem>
-              )}
-            />
-            <FormField
-              control={form.control}
-              name="social_emails"
-              render={({ field }) => (
-                <FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
-                  <div className="space-y-0.5">
-                    <FormLabel className="text-base">Social emails</FormLabel>
-                    <FormDescription>
-                      Receive emails for friend requests, follows, and more.
-                    </FormDescription>
-                  </div>
-                  <FormControl>
-                    <Switch
-                      checked={field.value}
-                      onCheckedChange={field.onChange}
-                    />
-                  </FormControl>
-                </FormItem>
-              )}
-            />
-            <FormField
-              control={form.control}
-              name="security_emails"
-              render={({ field }) => (
-                <FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
-                  <div className="space-y-0.5">
-                    <FormLabel className="text-base">Security emails</FormLabel>
-                    <FormDescription>
-                      Receive emails about your account activity and security.
-                    </FormDescription>
-                  </div>
-                  <FormControl>
-                    <Switch
-                      checked={field.value}
-                      onCheckedChange={field.onChange}
-                      disabled
-                      aria-readonly
-                    />
-                  </FormControl>
-                </FormItem>
-              )}
-            />
-          </div>
-        </div>
-        <FormField
-          control={form.control}
-          name="mobile"
-          render={({ field }) => (
-            <FormItem className="flex flex-row items-start space-x-3 space-y-0">
-              <FormControl>
-                <Checkbox
-                  checked={field.value}
-                  onCheckedChange={field.onChange}
-                />
-              </FormControl>
-              <div className="space-y-1 leading-none">
-                <FormLabel>
-                  Use different settings for my mobile devices
-                </FormLabel>
-                <FormDescription>
-                  You can manage your mobile notifications in the{" "}
-                  <Link href="/examples/forms">mobile settings</Link> page.
-                </FormDescription>
-              </div>
-            </FormItem>
-          )}
-        />
-        <Button type="submit">Update notifications</Button>
-      </form>
-    </Form>
-  )
-}

+ 0 - 17
src/app/profile/notifications/page.tsx

@@ -1,17 +0,0 @@
-import { Separator } from "@/components/ui/separator"
-import { NotificationsForm } from "./notifications-form"
-
-export default function SettingsNotificationsPage() {
-  return (
-    <div className="space-y-8">
-      <div>
-        <h3 className="text-lg font-medium">Notifications</h3>
-        <p className="text-sm text-muted-foreground">
-          Configure how you receive notifications.
-        </p>
-      </div>
-      <Separator />
-      <NotificationsForm />
-    </div>
-  )
-}

+ 0 - 17
src/app/profile/page.tsx

@@ -1,17 +0,0 @@
-import { Separator } from "@/components/ui/separator"
-import { ProfileForm } from "@app/components/profile-form"
-
-export default function SettingsProfilePage() {
-  return (
-    <div className="space-y-8">
-      <div>
-        <h3 className="text-lg font-medium">Profile</h3>
-        <p className="text-sm text-muted-foreground">
-          This is how others will see you on the site.
-        </p>
-      </div>
-      <Separator />
-      <ProfileForm />
-    </div>
-  )
-}

+ 5 - 0
src/app/profile/page_.tsx

@@ -0,0 +1,5 @@
+import { redirect } from "next/navigation";
+
+export default async function ProfilePage() {
+    redirect("/profile/general");
+}

+ 0 - 192
src/app/profile/profile-form.tsx

@@ -1,192 +0,0 @@
-"use client"
-
-import Link from "next/link"
-import { zodResolver } from "@hookform/resolvers/zod"
-import { useFieldArray, useForm } from "react-hook-form"
-import { z } from "zod"
-
-import { cn } from "@/lib/utils"
-import { toast } from "@/hooks/useToast"
-
-import { Button } from "@/components/ui/button"
-import {
-  Form,
-  FormControl,
-  FormDescription,
-  FormField,
-  FormItem,
-  FormLabel,
-  FormMessage,
-} from "@/components/ui/form"
-import { Input } from "@/components/ui/input"
-import {
-  Select,
-  SelectContent,
-  SelectItem,
-  SelectTrigger,
-  SelectValue,
-} from "@/components/ui/select"
-import { Textarea } from "@/components/ui/textarea"
-
-const profileFormSchema = z.object({
-  username: z
-    .string()
-    .min(2, {
-      message: "Username must be at least 2 characters.",
-    })
-    .max(30, {
-      message: "Username must not be longer than 30 characters.",
-    }),
-  email: z
-    .string({
-      required_error: "Please select an email to display.",
-    })
-    .email(),
-  bio: z.string().max(160).min(4),
-  urls: z
-    .array(
-      z.object({
-        value: z.string().url({ message: "Please enter a valid URL." }),
-      })
-    )
-    .optional(),
-})
-
-type ProfileFormValues = z.infer<typeof profileFormSchema>
-
-// This can come from your database or API.
-const defaultValues: Partial<ProfileFormValues> = {
-  bio: "I own a computer.",
-  urls: [
-    { value: "https://shadcn.com" },
-    { value: "http://twitter.com/shadcn" },
-  ],
-}
-
-export function ProfileForm() {
-  const form = useForm<ProfileFormValues>({
-    resolver: zodResolver(profileFormSchema),
-    defaultValues,
-    mode: "onChange",
-  })
-
-  const { fields, append } = useFieldArray({
-    name: "urls",
-    control: form.control,
-  })
-
-  function onSubmit(data: ProfileFormValues) {
-    toast({
-      title: "You submitted the following values:",
-      description: (
-        <pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
-          <code className="text-white">{JSON.stringify(data, null, 2)}</code>
-        </pre>
-      ),
-    })
-  }
-
-  return (
-    <Form {...form}>
-      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
-        <FormField
-          control={form.control}
-          name="username"
-          render={({ field }) => (
-            <FormItem>
-              <FormLabel>Username</FormLabel>
-              <FormControl>
-                <Input placeholder="shadcn" {...field} />
-              </FormControl>
-              <FormDescription>
-                This is your public display name. It can be your real name or a
-                pseudonym. You can only change this once every 30 days.
-              </FormDescription>
-              <FormMessage />
-            </FormItem>
-          )}
-        />
-        <FormField
-          control={form.control}
-          name="email"
-          render={({ field }) => (
-            <FormItem>
-              <FormLabel>Email</FormLabel>
-              <Select onValueChange={field.onChange} defaultValue={field.value}>
-                <FormControl>
-                  <SelectTrigger>
-                    <SelectValue placeholder="Select a verified email to display" />
-                  </SelectTrigger>
-                </FormControl>
-                <SelectContent>
-                  <SelectItem value="m@example.com">m@example.com</SelectItem>
-                  <SelectItem value="m@google.com">m@google.com</SelectItem>
-                  <SelectItem value="m@support.com">m@support.com</SelectItem>
-                </SelectContent>
-              </Select>
-              <FormDescription>
-                You can manage verified email addresses in your{" "}
-                <Link href="/examples/forms">email settings</Link>.
-              </FormDescription>
-              <FormMessage />
-            </FormItem>
-          )}
-        />
-        <FormField
-          control={form.control}
-          name="bio"
-          render={({ field }) => (
-            <FormItem>
-              <FormLabel>Bio</FormLabel>
-              <FormControl>
-                <Textarea
-                  placeholder="Tell us a little bit about yourself"
-                  className="resize-none"
-                  {...field}
-                />
-              </FormControl>
-              <FormDescription>
-                You can <span>@mention</span> other users and organizations to
-                link to them.
-              </FormDescription>
-              <FormMessage />
-            </FormItem>
-          )}
-        />
-        <div>
-          {fields.map((field, index) => (
-            <FormField
-              control={form.control}
-              key={field.id}
-              name={`urls.${index}.value`}
-              render={({ field }) => (
-                <FormItem>
-                  <FormLabel className={cn(index !== 0 && "sr-only")}>
-                    URLs
-                  </FormLabel>
-                  <FormDescription className={cn(index !== 0 && "sr-only")}>
-                    Add links to your website, blog, or social media profiles.
-                  </FormDescription>
-                  <FormControl>
-                    <Input {...field} />
-                  </FormControl>
-                  <FormMessage />
-                </FormItem>
-              )}
-            />
-          ))}
-          <Button
-            type="button"
-            variant="outline"
-            size="sm"
-            className="mt-2"
-            onClick={() => append({ value: "" })}
-          >
-            Add URL
-          </Button>
-        </div>
-        <Button type="submit">Update profile</Button>
-      </form>
-    </Form>
-  )
-}

+ 291 - 0
src/components/Enable2FaForm.tsx

@@ -0,0 +1,291 @@
+"use client";
+
+import { useState } from "react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { AlertCircle, CheckCircle2 } from "lucide-react";
+import { createApiClient } from "@app/api";
+import { useEnvContext } from "@app/hooks/useEnvContext";
+import { AxiosResponse } from "axios";
+import {
+    RequestTotpSecretBody,
+    RequestTotpSecretResponse,
+    VerifyTotpBody,
+    VerifyTotpResponse
+} from "@server/routers/auth";
+import { z } from "zod";
+import { useForm } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+import {
+    Form,
+    FormControl,
+    FormField,
+    FormItem,
+    FormLabel,
+    FormMessage
+} from "@app/components/ui/form";
+import {
+    Credenza,
+    CredenzaBody,
+    CredenzaClose,
+    CredenzaContent,
+    CredenzaDescription,
+    CredenzaFooter,
+    CredenzaHeader,
+    CredenzaTitle
+} from "@app/components/Credenza";
+import { useToast } from "@app/hooks/useToast";
+import { formatAxiosError } from "@app/lib/utils";
+import CopyTextBox from "@app/components/CopyTextBox";
+import { QRCodeSVG } from "qrcode.react";
+import { userUserContext } from "@app/hooks/useUserContext";
+
+const enableSchema = z.object({
+    password: z.string().min(1, { message: "Password is required" })
+});
+
+const confirmSchema = z.object({
+    code: z.string().length(6, { message: "Invalid code" })
+});
+
+type Enable2FaProps = {
+    open: boolean;
+    setOpen: (val: boolean) => void;
+};
+
+export default function Enable2FaForm({ open, setOpen }: Enable2FaProps) {
+    const [step, setStep] = useState(1);
+    const [secretKey, setSecretKey] = useState("");
+    const [verificationCode, setVerificationCode] = useState("");
+    const [error, setError] = useState("");
+    const [success, setSuccess] = useState(false);
+    const [loading, setLoading] = useState(false);
+    const [backupCodes, setBackupCodes] = useState<string[]>([]);
+
+    const { toast } = useToast();
+
+    const { user, updateUser } = userUserContext();
+
+    const api = createApiClient(useEnvContext());
+
+    const enableForm = useForm<z.infer<typeof enableSchema>>({
+        resolver: zodResolver(enableSchema),
+        defaultValues: {
+            password: ""
+        }
+    });
+
+    const confirmForm = useForm<z.infer<typeof confirmSchema>>({
+        resolver: zodResolver(confirmSchema),
+        defaultValues: {
+            code: ""
+        }
+    });
+
+    const request2fa = async (values: z.infer<typeof enableSchema>) => {
+        setLoading(true);
+
+        const res = await api
+            .post<AxiosResponse<RequestTotpSecretResponse>>(
+                `/auth/2fa/request`,
+                {
+                    password: values.password
+                } as RequestTotpSecretBody
+            )
+            .catch((e) => {
+                toast({
+                    title: "Unable to enable 2FA",
+                    description: formatAxiosError(
+                        e,
+                        "An error occurred while enabling 2FA"
+                    ),
+                    variant: "destructive"
+                });
+            });
+
+        if (res && res.data.data.secret) {
+            setSecretKey(res.data.data.secret);
+            setStep(2);
+        }
+
+        setLoading(false);
+    };
+
+    const confirm2fa = async (values: z.infer<typeof confirmSchema>) => {
+        setLoading(true);
+
+        const res = await api
+            .post<AxiosResponse<VerifyTotpResponse>>(`/auth/2fa/enable`, {
+                code: values.code
+            } as VerifyTotpBody)
+            .catch((e) => {
+                toast({
+                    title: "Unable to enable 2FA",
+                    description: formatAxiosError(
+                        e,
+                        "An error occurred while enabling 2FA"
+                    ),
+                    variant: "destructive"
+                });
+            });
+
+        if (res && res.data.data.valid) {
+            setBackupCodes(res.data.data.backupCodes || []);
+            updateUser({ twoFactorEnabled: true })
+            setStep(3);
+        }
+
+        setLoading(false);
+    };
+
+    const handleVerify = () => {
+        if (verificationCode.length !== 6) {
+            setError("Please enter a 6-digit code");
+            return;
+        }
+        if (verificationCode === "123456") {
+            setSuccess(true);
+            setStep(3);
+        } else {
+            setError("Invalid code. Please try again.");
+        }
+    };
+
+    return (
+        <Credenza
+            open={open}
+            onOpenChange={(val) => {
+                setOpen(val);
+                setLoading(false);
+            }}
+        >
+            <CredenzaContent>
+                <CredenzaHeader>
+                    <CredenzaTitle>
+                        Enable Two-factor Authentication
+                    </CredenzaTitle>
+                    <CredenzaDescription>
+                        Secure your account with an extra layer of protection
+                    </CredenzaDescription>
+                </CredenzaHeader>
+                <CredenzaBody>
+                    {step === 1 && (
+                        <Form {...enableForm}>
+                            <form
+                                onSubmit={enableForm.handleSubmit(request2fa)}
+                                className="space-y-4"
+                                id="form"
+                            >
+                                <div className="space-y-4">
+                                    <FormField
+                                        control={enableForm.control}
+                                        name="password"
+                                        render={({ field }) => (
+                                            <FormItem>
+                                                <FormLabel>Password</FormLabel>
+                                                <FormControl>
+                                                    <Input
+                                                        type="password"
+                                                        placeholder="Enter your password"
+                                                        {...field}
+                                                    />
+                                                </FormControl>
+                                                <FormMessage />
+                                            </FormItem>
+                                        )}
+                                    />
+                                </div>
+                            </form>
+                        </Form>
+                    )}
+
+                    {step === 2 && (
+                        <div className="space-y-4">
+                            <p>
+                                Scan this QR code with your authenticator app or
+                                enter the secret key manually:
+                            </p>
+                            <div className="w-64 h-64 mx-auto flex items-center justify-center">
+                                <QRCodeSVG value={secretKey} size={256} />
+                            </div>
+                            <div className="max-w-md mx-auto">
+                                <CopyTextBox
+                                    text={secretKey}
+                                    wrapText={false}
+                                />
+                            </div>
+
+                            <Form {...confirmForm}>
+                                <form
+                                    onSubmit={confirmForm.handleSubmit(
+                                        confirm2fa
+                                    )}
+                                    className="space-y-4"
+                                    id="form"
+                                >
+                                    <div className="space-y-4">
+                                        <FormField
+                                            control={confirmForm.control}
+                                            name="code"
+                                            render={({ field }) => (
+                                                <FormItem>
+                                                    <FormLabel>
+                                                        Verification Code
+                                                    </FormLabel>
+                                                    <FormControl>
+                                                        <Input
+                                                            type="code"
+                                                            placeholder="Enter the 6-digit code from your authenticator app"
+                                                            {...field}
+                                                        />
+                                                    </FormControl>
+                                                    <FormMessage />
+                                                </FormItem>
+                                            )}
+                                        />
+                                    </div>
+                                </form>
+                            </Form>
+                        </div>
+                    )}
+
+                    {step === 3 && (
+                        <div className="space-y-4 text-center">
+                            <CheckCircle2
+                                className="mx-auto text-green-500"
+                                size={48}
+                            />
+                            <p className="font-semibold text-lg">
+                                Two-Factor Authentication Enabled
+                            </p>
+                            <p>
+                                Your account is now more secure. Don't forget to
+                                save your backup codes.
+                            </p>
+
+                            <div className="max-w-md mx-auto">
+                                <CopyTextBox text={backupCodes.join("\n")} />
+                            </div>
+                        </div>
+                    )}
+                </CredenzaBody>
+                <CredenzaFooter>
+                    {(step === 1 || step === 2) && (
+                        <Button
+                            type="submit"
+                            form="form"
+                            loading={loading}
+                            disabled={loading}
+                        >
+                            Submit
+                        </Button>
+                    )}
+                    <CredenzaClose asChild>
+                        <Button variant="outline">Close</Button>
+                    </CredenzaClose>
+                </CredenzaFooter>
+            </CredenzaContent>
+        </Credenza>
+    );
+}

+ 101 - 95
src/app/[orgId]/settings/components/Header.tsx → src/components/Header.tsx

@@ -15,7 +15,6 @@ import {
 import {
     DropdownMenu,
     DropdownMenuContent,
-    DropdownMenuGroup,
     DropdownMenuItem,
     DropdownMenuLabel,
     DropdownMenuSeparator,
@@ -26,14 +25,6 @@ import {
     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";
@@ -45,40 +36,39 @@ import {
     LogOut,
     Moon,
     Plus,
-    Sun,
-    User
+    Sun
 } from "lucide-react";
 import { useTheme } from "next-themes";
 import Link from "next/link";
 import { useRouter } from "next/navigation";
 import { useState } from "react";
+import Enable2FaForm from "./Enable2FaForm";
+import { userUserContext } from "@app/hooks/useUserContext";
 
 type HeaderProps = {
-    name?: string;
-    email: string;
-    orgId: string;
-    orgs: ListOrgsResponse["orgs"];
+    orgId?: string;
+    orgs?: ListOrgsResponse["orgs"];
 };
 
-export default function Header({ email, orgId, name, orgs }: HeaderProps) {
+export function Header({ orgId, orgs }: HeaderProps) {
     const { toast } = useToast();
     const { setTheme, theme } = useTheme();
 
+    const { user, updateUser } = userUserContext();
+
     const [open, setOpen] = useState(false);
     const [userTheme, setUserTheme] = useState<"light" | "dark" | "system">(
         theme as "light" | "dark" | "system"
     );
 
+    const [openEnable2fa, setOpenEnable2fa] = useState(false);
+
     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();
+        return user.email.substring(0, 2).toUpperCase();
     }
 
     function logout() {
@@ -102,6 +92,8 @@ export default function Header({ email, orgId, name, orgs }: HeaderProps) {
 
     return (
         <>
+            <Enable2FaForm open={openEnable2fa} setOpen={setOpenEnable2fa} />
+
             <div className="flex items-center justify-between">
                 <div className="flex items-center gap-4">
                     <DropdownMenu>
@@ -128,15 +120,23 @@ export default function Header({ email, orgId, name, orgs }: HeaderProps) {
                                         Signed in as
                                     </p>
                                     <p className="text-xs leading-none text-muted-foreground">
-                                        {email}
+                                        {user.email}
                                     </p>
                                 </div>
                             </DropdownMenuLabel>
                             <DropdownMenuSeparator />
-                            <DropdownMenuItem>
-                                <User className="mr-2 h-4 w-4" />
-                                <span>User Settings</span>
-                            </DropdownMenuItem>
+                            {!user.twoFactorEnabled && (
+                                <DropdownMenuItem
+                                    onClick={() => setOpenEnable2fa(true)}
+                                >
+                                    <span>Enable Two-factor</span>
+                                </DropdownMenuItem>
+                            )}
+                            {user.twoFactorEnabled && (
+                                <DropdownMenuItem>
+                                    <span>Disable Two-factor</span>
+                                </DropdownMenuItem>
+                            )}
                             <DropdownMenuSeparator />
                             <DropdownMenuLabel>Theme</DropdownMenuLabel>
                             {(["light", "dark", "system"] as const).map(
@@ -175,7 +175,7 @@ export default function Header({ email, orgId, name, orgs }: HeaderProps) {
                         </DropdownMenuContent>
                     </DropdownMenu>
                     <span className="truncate max-w-[150px] md:max-w-none font-medium">
-                        {name || email}
+                        {user.email}
                     </span>
                 </div>
 
@@ -197,82 +197,88 @@ export default function Header({ email, orgId, name, orgs }: HeaderProps) {
                         </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>
+                    {orgs && (
+                        <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
+                                                    : "None selected"}
+                                            </span>
+                                        </div>
+                                        <ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
                                     </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) => (
+                                </Button>
+                            </PopoverTrigger>
+                            <PopoverContent className="[100px] md:w-[180px] p-0">
+                                <Command>
+                                    <CommandInput placeholder="Search..." />
+                                    <CommandEmpty>
+                                        No organizations found.
+                                    </CommandEmpty>
+                                    <CommandGroup heading="Create">
+                                        <CommandList>
                                             <CommandItem
-                                                key={org.orgId}
                                                 onSelect={(currentValue) => {
-                                                    router.push(
-                                                        `/${org.orgId}/settings`
-                                                    );
+                                                    router.push("/setup");
                                                 }}
                                             >
-                                                <Check
-                                                    className={cn(
-                                                        "mr-2 h-4 w-4",
-                                                        orgId === org.orgId
-                                                            ? "opacity-100"
-                                                            : "opacity-0"
-                                                    )}
-                                                />
-                                                {org.name}
+                                                <Plus className="mr-2 h-4 w-4" />
+                                                New Organization
                                             </CommandItem>
-                                        ))}
-                                    </CommandList>
-                                </CommandGroup>
-                            </Command>
-                        </PopoverContent>
-                    </Popover>
+                                        </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>
         </>
     );
 }
+
+export default Header;

+ 7 - 1
src/components/LoginForm.tsx

@@ -214,7 +214,13 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
                                     <FormLabel>Authenticator Code</FormLabel>
                                     <FormControl>
                                         <div className="flex justify-center">
-                                            <InputOTP maxLength={6} {...field} pattern={REGEXP_ONLY_DIGITS_AND_CHARS}>
+                                            <InputOTP
+                                                maxLength={6}
+                                                {...field}
+                                                pattern={
+                                                    REGEXP_ONLY_DIGITS_AND_CHARS
+                                                }
+                                            >
                                                 <InputOTPGroup>
                                                     <InputOTPSlot index={0} />
                                                     <InputOTPSlot index={1} />

+ 3 - 3
src/app/[orgId]/settings/components/TopbarNav.tsx → src/components/TopbarNav.tsx

@@ -12,7 +12,7 @@ interface TopbarNavProps extends React.HTMLAttributes<HTMLElement> {
         icon: React.ReactNode;
     }[];
     disabled?: boolean;
-    orgId: string;
+    orgId?: string;
 }
 
 export function TopbarNav({
@@ -36,10 +36,10 @@ export function TopbarNav({
             {items.map((item) => (
                 <Link
                     key={item.href}
-                    href={item.href.replace("{orgId}", orgId)}
+                    href={item.href.replace("{orgId}", orgId || "")}
                     className={cn(
                         "relative px-3 py-3 text-md",
-                        pathname.startsWith(item.href.replace("{orgId}", orgId))
+                        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",

+ 0 - 176
src/components/account-form.tsx

@@ -1,176 +0,0 @@
-"use client"
-
-import { zodResolver } from "@hookform/resolvers/zod"
-import { CalendarIcon, CaretSortIcon, CheckIcon } from "@radix-ui/react-icons"
-import { useForm } from "react-hook-form"
-import { z } from "zod"
-
-import { cn } from "@/lib/utils"
-import { toast } from "@/hooks/useToast"
-import { Button } from "@/components/ui/button"
-import {
-  Command,
-  CommandEmpty,
-  CommandGroup,
-  CommandInput,
-  CommandItem,
-  CommandList,
-} from "@/components/ui/command"
-import {
-  Form,
-  FormControl,
-  FormDescription,
-  FormField,
-  FormItem,
-  FormLabel,
-  FormMessage,
-} from "@/components/ui/form"
-import { Input } from "@/components/ui/input"
-import {
-  Popover,
-  PopoverContent,
-  PopoverTrigger,
-} from "@/components/ui/popover"
-
-const languages = [
-  { label: "English", value: "en" },
-  { label: "French", value: "fr" },
-  { label: "German", value: "de" },
-  { label: "Spanish", value: "es" },
-  { label: "Portuguese", value: "pt" },
-  { label: "Russian", value: "ru" },
-  { label: "Japanese", value: "ja" },
-  { label: "Korean", value: "ko" },
-  { label: "Chinese", value: "zh" },
-] as const
-
-const accountFormSchema = z.object({
-  name: z
-    .string()
-    .min(2, {
-      message: "Name must be at least 2 characters.",
-    })
-    .max(30, {
-      message: "Name must not be longer than 30 characters.",
-    }),
-  dob: z.date({
-    required_error: "A date of birth is required.",
-  }),
-  language: z.string({
-    required_error: "Please select a language.",
-  }),
-})
-
-type AccountFormValues = z.infer<typeof accountFormSchema>
-
-// This can come from your database or API.
-const defaultValues: Partial<AccountFormValues> = {
-  // name: "Your name",
-  // dob: new Date("2023-01-23"),
-}
-
-export function AccountForm() {
-  const form = useForm<AccountFormValues>({
-    resolver: zodResolver(accountFormSchema),
-    defaultValues,
-  })
-
-  function onSubmit(data: AccountFormValues) {
-    toast({
-      title: "You submitted the following values:",
-      description: (
-        <pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
-          <code className="text-white">{JSON.stringify(data, null, 2)}</code>
-        </pre>
-      ),
-    })
-  }
-
-  return (
-    <Form {...form}>
-      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
-        <FormField
-          control={form.control}
-          name="name"
-          render={({ field }) => (
-            <FormItem>
-              <FormLabel>Name</FormLabel>
-              <FormControl>
-                <Input placeholder="Your name" {...field} />
-              </FormControl>
-              <FormDescription>
-                This is the name that will be displayed on your profile and in
-                emails.
-              </FormDescription>
-              <FormMessage />
-            </FormItem>
-          )}
-        />
-        <FormField
-          control={form.control}
-          name="language"
-          render={({ field }) => (
-            <FormItem className="flex flex-col">
-              <FormLabel>Language</FormLabel>
-              <Popover>
-                <PopoverTrigger asChild>
-                  <FormControl>
-                    <Button
-                      variant="outline"
-                      role="combobox"
-                      className={cn(
-                        "w-[200px] justify-between",
-                        !field.value && "text-muted-foreground"
-                      )}
-                    >
-                      {field.value
-                        ? languages.find(
-                            (language) => language.value === field.value
-                          )?.label
-                        : "Select language"}
-                      <CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
-                    </Button>
-                  </FormControl>
-                </PopoverTrigger>
-                <PopoverContent className="w-[200px] p-0">
-                  <Command>
-                    <CommandInput placeholder="Search language..." />
-                    <CommandList>
-                      <CommandEmpty>No language found.</CommandEmpty>
-                      <CommandGroup>
-                        {languages.map((language) => (
-                          <CommandItem
-                            value={language.label}
-                            key={language.value}
-                            onSelect={() => {
-                              form.setValue("language", language.value)
-                            }}
-                          >
-                            <CheckIcon
-                              className={cn(
-                                "mr-2 h-4 w-4",
-                                language.value === field.value
-                                  ? "opacity-100"
-                                  : "opacity-0"
-                              )}
-                            />
-                            {language.label}
-                          </CommandItem>
-                        ))}
-                      </CommandGroup>
-                    </CommandList>
-                  </Command>
-                </PopoverContent>
-              </Popover>
-              <FormDescription>
-                This is the language that will be used in the dashboard.
-              </FormDescription>
-              <FormMessage />
-            </FormItem>
-          )}
-        />
-        <Button type="submit">Update account</Button>
-      </form>
-    </Form>
-  )
-}

+ 0 - 179
src/components/appearance-form.tsx

@@ -1,179 +0,0 @@
-"use client";
-
-import { zodResolver } from "@hookform/resolvers/zod";
-import { ChevronDownIcon } from "@radix-ui/react-icons";
-import { useForm } from "react-hook-form";
-import { z } from "zod";
-
-import { cn } from "@/lib/utils";
-import { toast } from "@/hooks/useToast";
-import { Button, buttonVariants } from "@/components/ui/button";
-import {
-    Form,
-    FormControl,
-    FormDescription,
-    FormField,
-    FormItem,
-    FormLabel,
-    FormMessage,
-} from "@/components/ui/form";
-import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
-import { useSiteContext } from "@app/hooks/useSiteContext";
-
-const appearanceFormSchema = z.object({
-    theme: z.enum(["light", "dark"], {
-        required_error: "Please select a theme.",
-    }),
-    font: z.enum(["inter", "manrope", "system"], {
-        invalid_type_error: "Select a font",
-        required_error: "Please select a font.",
-    }),
-});
-
-type AppearanceFormValues = z.infer<typeof appearanceFormSchema>;
-
-// This can come from your database or API.
-const defaultValues: Partial<AppearanceFormValues> = {
-    theme: "light",
-};
-
-export function AppearanceForm() {
-    const site = useSiteContext();
-
-    console.log(site);
-
-    const form = useForm<AppearanceFormValues>({
-        resolver: zodResolver(appearanceFormSchema),
-        defaultValues,
-    });
-
-    function onSubmit(data: AppearanceFormValues) {
-        toast({
-            title: "You submitted the following values:",
-            description: (
-                <pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
-                    <code className="text-white">
-                        {JSON.stringify(data, null, 2)}
-                    </code>
-                </pre>
-            ),
-        });
-    }
-
-    return (
-        <Form {...form}>
-            <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
-                <FormField
-                    control={form.control}
-                    name="font"
-                    render={({ field }) => (
-                        <FormItem>
-                            <FormLabel>Font</FormLabel>
-                            <div className="relative w-max">
-                                <FormControl>
-                                    <select
-                                        className={cn(
-                                            buttonVariants({
-                                                variant: "outline",
-                                            }),
-                                            "w-[200px] appearance-none font-normal"
-                                        )}
-                                        {...field}
-                                    >
-                                        <option value="inter">Inter</option>
-                                        <option value="manrope">Manrope</option>
-                                        <option value="system">System</option>
-                                    </select>
-                                </FormControl>
-                                <ChevronDownIcon className="absolute right-3 top-2.5 h-4 w-4 opacity-50" />
-                            </div>
-                            <FormDescription>
-                                Set the font you want to use in the dashboard.
-                            </FormDescription>
-                            <FormMessage />
-                        </FormItem>
-                    )}
-                />
-                <FormField
-                    control={form.control}
-                    name="theme"
-                    render={({ field }) => (
-                        <FormItem className="space-y-1">
-                            <FormLabel>Theme</FormLabel>
-                            <FormDescription>
-                                Select the theme for the dashboard.
-                            </FormDescription>
-                            <FormMessage />
-                            <RadioGroup
-                                onValueChange={field.onChange}
-                                defaultValue={field.value}
-                                className="grid max-w-md grid-cols-2 gap-8 pt-2"
-                            >
-                                <FormItem>
-                                    <FormLabel className="[&:has([data-state=checked])>div]:border-primary">
-                                        <FormControl>
-                                            <RadioGroupItem
-                                                value="light"
-                                                className="sr-only"
-                                            />
-                                        </FormControl>
-                                        <div className="items-center rounded-md border-2 border-muted p-1 hover:border-accent">
-                                            <div className="space-y-2 rounded-sm bg-[#ecedef] p-2">
-                                                <div className="space-y-2 rounded-md bg-white p-2 shadow-sm">
-                                                    <div className="h-2 w-[80px] rounded-lg bg-[#ecedef]" />
-                                                    <div className="h-2 w-[100px] rounded-lg bg-[#ecedef]" />
-                                                </div>
-                                                <div className="flex items-center space-x-2 rounded-md bg-white p-2 shadow-sm">
-                                                    <div className="h-4 w-4 rounded-full bg-[#ecedef]" />
-                                                    <div className="h-2 w-[100px] rounded-lg bg-[#ecedef]" />
-                                                </div>
-                                                <div className="flex items-center space-x-2 rounded-md bg-white p-2 shadow-sm">
-                                                    <div className="h-4 w-4 rounded-full bg-[#ecedef]" />
-                                                    <div className="h-2 w-[100px] rounded-lg bg-[#ecedef]" />
-                                                </div>
-                                            </div>
-                                        </div>
-                                        <span className="block w-full p-2 text-center font-normal">
-                                            Light
-                                        </span>
-                                    </FormLabel>
-                                </FormItem>
-                                <FormItem>
-                                    <FormLabel className="[&:has([data-state=checked])>div]:border-primary">
-                                        <FormControl>
-                                            <RadioGroupItem
-                                                value="dark"
-                                                className="sr-only"
-                                            />
-                                        </FormControl>
-                                        <div className="items-center rounded-md border-2 border-muted bg-popover p-1 hover:bg-accent hover:text-accent-foreground">
-                                            <div className="space-y-2 rounded-sm bg-slate-950 p-2">
-                                                <div className="space-y-2 rounded-md bg-slate-800 p-2 shadow-sm">
-                                                    <div className="h-2 w-[80px] rounded-lg bg-slate-400" />
-                                                    <div className="h-2 w-[100px] rounded-lg bg-slate-400" />
-                                                </div>
-                                                <div className="flex items-center space-x-2 rounded-md bg-slate-800 p-2 shadow-sm">
-                                                    <div className="h-4 w-4 rounded-full bg-slate-400" />
-                                                    <div className="h-2 w-[100px] rounded-lg bg-slate-400" />
-                                                </div>
-                                                <div className="flex items-center space-x-2 rounded-md bg-slate-800 p-2 shadow-sm">
-                                                    <div className="h-4 w-4 rounded-full bg-slate-400" />
-                                                    <div className="h-2 w-[100px] rounded-lg bg-slate-400" />
-                                                </div>
-                                            </div>
-                                        </div>
-                                        <span className="block w-full p-2 text-center font-normal">
-                                            Dark
-                                        </span>
-                                    </FormLabel>
-                                </FormItem>
-                            </RadioGroup>
-                        </FormItem>
-                    )}
-                />
-
-                <Button type="submit">Update preferences</Button>
-            </form>
-        </Form>
-    );
-}

+ 0 - 132
src/components/display-form.tsx

@@ -1,132 +0,0 @@
-"use client"
-
-import { zodResolver } from "@hookform/resolvers/zod"
-import { useForm } from "react-hook-form"
-import { z } from "zod"
-
-import { toast } from "@/hooks/useToast"
-import { Button } from "@/components/ui/button"
-import { Checkbox } from "@/components/ui/checkbox"
-import {
-  Form,
-  FormControl,
-  FormDescription,
-  FormField,
-  FormItem,
-  FormLabel,
-  FormMessage,
-} from "@/components/ui/form"
-
-const items = [
-  {
-    id: "recents",
-    label: "Recents",
-  },
-  {
-    id: "home",
-    label: "Home",
-  },
-  {
-    id: "applications",
-    label: "Applications",
-  },
-  {
-    id: "desktop",
-    label: "Desktop",
-  },
-  {
-    id: "downloads",
-    label: "Downloads",
-  },
-  {
-    id: "documents",
-    label: "Documents",
-  },
-] as const
-
-const displayFormSchema = z.object({
-  items: z.array(z.string()).refine((value) => value.some((item) => item), {
-    message: "You have to select at least one item.",
-  }),
-})
-
-type DisplayFormValues = z.infer<typeof displayFormSchema>
-
-// This can come from your database or API.
-const defaultValues: Partial<DisplayFormValues> = {
-  items: ["recents", "home"],
-}
-
-export function DisplayForm() {
-  const form = useForm<DisplayFormValues>({
-    resolver: zodResolver(displayFormSchema),
-    defaultValues,
-  })
-
-  function onSubmit(data: DisplayFormValues) {
-    toast({
-      title: "You submitted the following values:",
-      description: (
-        <pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
-          <code className="text-white">{JSON.stringify(data, null, 2)}</code>
-        </pre>
-      ),
-    })
-  }
-
-  return (
-    <Form {...form}>
-      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
-        <FormField
-          control={form.control}
-          name="items"
-          render={() => (
-            <FormItem>
-              <div className="mb-4">
-                <FormLabel className="text-base">Sidebar</FormLabel>
-                <FormDescription>
-                  Select the items you want to display in the sidebar.
-                </FormDescription>
-              </div>
-              {items.map((item) => (
-                <FormField
-                  key={item.id}
-                  control={form.control}
-                  name="items"
-                  render={({ field }) => {
-                    return (
-                      <FormItem
-                        key={item.id}
-                        className="flex flex-row items-start space-x-3 space-y-0"
-                      >
-                        <FormControl>
-                          <Checkbox
-                            checked={field.value?.includes(item.id)}
-                            onCheckedChange={(checked) => {
-                              return checked
-                                ? field.onChange([...field.value, item.id])
-                                : field.onChange(
-                                    field.value?.filter(
-                                      (value) => value !== item.id
-                                    )
-                                  )
-                            }}
-                          />
-                        </FormControl>
-                        <FormLabel className="font-normal">
-                          {item.label}
-                        </FormLabel>
-                      </FormItem>
-                    )
-                  }}
-                />
-              ))}
-              <FormMessage />
-            </FormItem>
-          )}
-        />
-        <Button type="submit">Update display</Button>
-      </form>
-    </Form>
-  )
-}

+ 0 - 222
src/components/notifications-form.tsx

@@ -1,222 +0,0 @@
-"use client"
-
-import Link from "next/link"
-import { zodResolver } from "@hookform/resolvers/zod"
-import { useForm } from "react-hook-form"
-import { z } from "zod"
-
-import { toast } from "@/hooks/useToast"
-import { Button } from "@/components/ui/button"
-import { Checkbox } from "@/components/ui/checkbox"
-import {
-  Form,
-  FormControl,
-  FormDescription,
-  FormField,
-  FormItem,
-  FormLabel,
-  FormMessage,
-} from "@/components/ui/form"
-import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
-import { Switch } from "@/components/ui/switch"
-
-const notificationsFormSchema = z.object({
-  type: z.enum(["all", "mentions", "none"], {
-    required_error: "You need to select a notification type.",
-  }),
-  mobile: z.boolean().default(false).optional(),
-  communication_emails: z.boolean().default(false).optional(),
-  social_emails: z.boolean().default(false).optional(),
-  marketing_emails: z.boolean().default(false).optional(),
-  security_emails: z.boolean(),
-})
-
-type NotificationsFormValues = z.infer<typeof notificationsFormSchema>
-
-// This can come from your database or API.
-const defaultValues: Partial<NotificationsFormValues> = {
-  communication_emails: false,
-  marketing_emails: false,
-  social_emails: true,
-  security_emails: true,
-}
-
-export function NotificationsForm() {
-  const form = useForm<NotificationsFormValues>({
-    resolver: zodResolver(notificationsFormSchema),
-    defaultValues,
-  })
-
-  function onSubmit(data: NotificationsFormValues) {
-    toast({
-      title: "You submitted the following values:",
-      description: (
-        <pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
-          <code className="text-white">{JSON.stringify(data, null, 2)}</code>
-        </pre>
-      ),
-    })
-  }
-
-  return (
-    <Form {...form}>
-      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
-        <FormField
-          control={form.control}
-          name="type"
-          render={({ field }) => (
-            <FormItem className="space-y-3">
-              <FormLabel>Notify me about...</FormLabel>
-              <FormControl>
-                <RadioGroup
-                  onValueChange={field.onChange}
-                  defaultValue={field.value}
-                  className="flex flex-col space-y-1"
-                >
-                  <FormItem className="flex items-center space-x-3 space-y-0">
-                    <FormControl>
-                      <RadioGroupItem value="all" />
-                    </FormControl>
-                    <FormLabel className="font-normal">
-                      All new messages
-                    </FormLabel>
-                  </FormItem>
-                  <FormItem className="flex items-center space-x-3 space-y-0">
-                    <FormControl>
-                      <RadioGroupItem value="mentions" />
-                    </FormControl>
-                    <FormLabel className="font-normal">
-                      Direct messages and mentions
-                    </FormLabel>
-                  </FormItem>
-                  <FormItem className="flex items-center space-x-3 space-y-0">
-                    <FormControl>
-                      <RadioGroupItem value="none" />
-                    </FormControl>
-                    <FormLabel className="font-normal">Nothing</FormLabel>
-                  </FormItem>
-                </RadioGroup>
-              </FormControl>
-              <FormMessage />
-            </FormItem>
-          )}
-        />
-        <div>
-          <h3 className="mb-4 text-lg font-medium">Email Notifications</h3>
-          <div className="space-y-4">
-            <FormField
-              control={form.control}
-              name="communication_emails"
-              render={({ field }) => (
-                <FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
-                  <div className="space-y-0.5">
-                    <FormLabel className="text-base">
-                      Communication emails
-                    </FormLabel>
-                    <FormDescription>
-                      Receive emails about your account activity.
-                    </FormDescription>
-                  </div>
-                  <FormControl>
-                    <Switch
-                      checked={field.value}
-                      onCheckedChange={field.onChange}
-                    />
-                  </FormControl>
-                </FormItem>
-              )}
-            />
-            <FormField
-              control={form.control}
-              name="marketing_emails"
-              render={({ field }) => (
-                <FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
-                  <div className="space-y-0.5">
-                    <FormLabel className="text-base">
-                      Marketing emails
-                    </FormLabel>
-                    <FormDescription>
-                      Receive emails about new products, features, and more.
-                    </FormDescription>
-                  </div>
-                  <FormControl>
-                    <Switch
-                      checked={field.value}
-                      onCheckedChange={field.onChange}
-                    />
-                  </FormControl>
-                </FormItem>
-              )}
-            />
-            <FormField
-              control={form.control}
-              name="social_emails"
-              render={({ field }) => (
-                <FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
-                  <div className="space-y-0.5">
-                    <FormLabel className="text-base">Social emails</FormLabel>
-                    <FormDescription>
-                      Receive emails for friend requests, follows, and more.
-                    </FormDescription>
-                  </div>
-                  <FormControl>
-                    <Switch
-                      checked={field.value}
-                      onCheckedChange={field.onChange}
-                    />
-                  </FormControl>
-                </FormItem>
-              )}
-            />
-            <FormField
-              control={form.control}
-              name="security_emails"
-              render={({ field }) => (
-                <FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
-                  <div className="space-y-0.5">
-                    <FormLabel className="text-base">Security emails</FormLabel>
-                    <FormDescription>
-                      Receive emails about your account activity and security.
-                    </FormDescription>
-                  </div>
-                  <FormControl>
-                    <Switch
-                      checked={field.value}
-                      onCheckedChange={field.onChange}
-                      disabled
-                      aria-readonly
-                    />
-                  </FormControl>
-                </FormItem>
-              )}
-            />
-          </div>
-        </div>
-        <FormField
-          control={form.control}
-          name="mobile"
-          render={({ field }) => (
-            <FormItem className="flex flex-row items-start space-x-3 space-y-0">
-              <FormControl>
-                <Checkbox
-                  checked={field.value}
-                  onCheckedChange={field.onChange}
-                />
-              </FormControl>
-              <div className="space-y-1 leading-none">
-                <FormLabel>
-                  Use different settings for my mobile devices
-                </FormLabel>
-                <FormDescription>
-                  You can manage your mobile notifications in the{" "}
-                  <Link href="/examples/forms">mobile settings</Link> page.
-                </FormDescription>
-              </div>
-            </FormItem>
-          )}
-        />
-        <Button type="submit">Update notifications</Button>
-      </form>
-    </Form>
-  )
-}

+ 0 - 192
src/components/profile-form.tsx

@@ -1,192 +0,0 @@
-"use client"
-
-import Link from "next/link"
-import { zodResolver } from "@hookform/resolvers/zod"
-import { useFieldArray, useForm } from "react-hook-form"
-import { z } from "zod"
-
-import { cn } from "@/lib/utils"
-import { toast } from "@/hooks/useToast"
-
-import { Button } from "@/components/ui/button"
-import {
-  Form,
-  FormControl,
-  FormDescription,
-  FormField,
-  FormItem,
-  FormLabel,
-  FormMessage,
-} from "@/components/ui/form"
-import { Input } from "@/components/ui/input"
-import {
-  Select,
-  SelectContent,
-  SelectItem,
-  SelectTrigger,
-  SelectValue,
-} from "@/components/ui/select"
-import { Textarea } from "@/components/ui/textarea"
-
-const profileFormSchema = z.object({
-  username: z
-    .string()
-    .min(2, {
-      message: "Username must be at least 2 characters.",
-    })
-    .max(30, {
-      message: "Username must not be longer than 30 characters.",
-    }),
-  email: z
-    .string({
-      required_error: "Please select an email to display.",
-    })
-    .email(),
-  bio: z.string().max(160).min(4),
-  urls: z
-    .array(
-      z.object({
-        value: z.string().url({ message: "Please enter a valid URL." }),
-      })
-    )
-    .optional(),
-})
-
-type ProfileFormValues = z.infer<typeof profileFormSchema>
-
-// This can come from your database or API.
-const defaultValues: Partial<ProfileFormValues> = {
-  bio: "I own a computer.",
-  urls: [
-    { value: "https://shadcn.com" },
-    { value: "http://twitter.com/shadcn" },
-  ],
-}
-
-export function ProfileForm() {
-  const form = useForm<ProfileFormValues>({
-    resolver: zodResolver(profileFormSchema),
-    defaultValues,
-    mode: "onChange",
-  })
-
-  const { fields, append } = useFieldArray({
-    name: "urls",
-    control: form.control,
-  })
-
-  function onSubmit(data: ProfileFormValues) {
-    toast({
-      title: "You submitted the following values:",
-      description: (
-        <pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
-          <code className="text-white">{JSON.stringify(data, null, 2)}</code>
-        </pre>
-      ),
-    })
-  }
-
-  return (
-    <Form {...form}>
-      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
-        <FormField
-          control={form.control}
-          name="username"
-          render={({ field }) => (
-            <FormItem>
-              <FormLabel>Username</FormLabel>
-              <FormControl>
-                <Input placeholder="shadcn" {...field} />
-              </FormControl>
-              <FormDescription>
-                This is your public display name. It can be your real name or a
-                pseudonym. You can only change this once every 30 days.
-              </FormDescription>
-              <FormMessage />
-            </FormItem>
-          )}
-        />
-        <FormField
-          control={form.control}
-          name="email"
-          render={({ field }) => (
-            <FormItem>
-              <FormLabel>Email</FormLabel>
-              <Select onValueChange={field.onChange} defaultValue={field.value}>
-                <FormControl>
-                  <SelectTrigger>
-                    <SelectValue placeholder="Select a verified email to display" />
-                  </SelectTrigger>
-                </FormControl>
-                <SelectContent>
-                  <SelectItem value="m@example.com">m@example.com</SelectItem>
-                  <SelectItem value="m@google.com">m@google.com</SelectItem>
-                  <SelectItem value="m@support.com">m@support.com</SelectItem>
-                </SelectContent>
-              </Select>
-              <FormDescription>
-                You can manage verified email addresses in your{" "}
-                <Link href="/examples/forms">email settings</Link>.
-              </FormDescription>
-              <FormMessage />
-            </FormItem>
-          )}
-        />
-        <FormField
-          control={form.control}
-          name="bio"
-          render={({ field }) => (
-            <FormItem>
-              <FormLabel>Bio</FormLabel>
-              <FormControl>
-                <Textarea
-                  placeholder="Tell us a little bit about yourself"
-                  className="resize-none"
-                  {...field}
-                />
-              </FormControl>
-              <FormDescription>
-                You can <span>@mention</span> other users and organizations to
-                link to them.
-              </FormDescription>
-              <FormMessage />
-            </FormItem>
-          )}
-        />
-        <div>
-          {fields.map((field, index) => (
-            <FormField
-              control={form.control}
-              key={field.id}
-              name={`urls.${index}.value`}
-              render={({ field }) => (
-                <FormItem>
-                  <FormLabel className={cn(index !== 0 && "sr-only")}>
-                    URLs
-                  </FormLabel>
-                  <FormDescription className={cn(index !== 0 && "sr-only")}>
-                    Add links to your website, blog, or social media profiles.
-                  </FormDescription>
-                  <FormControl>
-                    <Input {...field} />
-                  </FormControl>
-                  <FormMessage />
-                </FormItem>
-              )}
-            />
-          ))}
-          <Button
-            type="button"
-            variant="outline"
-            size="sm"
-            className="mt-2"
-            onClick={() => append({ value: "" })}
-          >
-            Add URL
-          </Button>
-        </div>
-        <Button type="submit">Update profile</Button>
-      </form>
-    </Form>
-  )
-}

+ 8 - 1
src/contexts/userContext.ts

@@ -1,4 +1,11 @@
 import { GetUserResponse } from "@server/routers/user";
 import { createContext } from "react";
 
-export const UserContext = createContext<GetUserResponse | null>(null);
+interface UserContextType {
+    user: GetUserResponse;
+    updateUser: (updatedUser: Partial<GetUserResponse>) => void;
+}
+
+const UserContext = createContext<UserContextType | undefined>(undefined);
+
+export default UserContext;

+ 7 - 4
src/hooks/useUserContext.ts

@@ -1,7 +1,10 @@
-import { UserContext } from "@app/contexts/userContext";
+import UserContext from "@app/contexts/userContext";
 import { useContext } from "react";
 
-export function useUserContext() {
-    const user = useContext(UserContext);
-    return user;
+export function userUserContext() {
+    const context = useContext(UserContext);
+    if (context === undefined) {
+        throw new Error("useUserContext must be used within a UserProvider");
+    }
+    return context;
 }

+ 28 - 7
src/providers/UserProvider.tsx

@@ -1,16 +1,37 @@
 "use client";
 
-import { UserContext } from "@app/contexts/userContext";
+import UserContext from "@app/contexts/userContext";
 import { GetUserResponse } from "@server/routers/user";
-import { ReactNode } from "react";
+import { useState } from "react";
 
-type UserProviderProps = {
+interface UserProviderProps {
+    children: React.ReactNode;
     user: GetUserResponse;
-    children: ReactNode;
-};
+}
+
+export function UserProvider({ children, user: u }: UserProviderProps) {
+    const [user, setUser] = useState<GetUserResponse>(u);
+
+    const updateUser = (updatedUser: Partial<GetUserResponse>) => {
+        if (!user) {
+            throw new Error("No user to update");
+        }
+        setUser((prev) => {
+            if (!prev) {
+                return prev;
+            }
+            return {
+                ...prev,
+                ...updatedUser
+            };
+        });
+    };
 
-export function UserProvider({ user, children }: UserProviderProps) {
-    return <UserContext.Provider value={user}>{children}</UserContext.Provider>;
+    return (
+        <UserContext.Provider value={{ user: user, updateUser: updateUser }}>
+            {children}
+        </UserContext.Provider>
+    );
 }
 
 export default UserProvider;