diff --git a/package.json b/package.json
index 8918efb2..c698e43b 100644
--- a/package.json
+++ b/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",
diff --git a/server/routers/auth/verifyTotp.ts b/server/routers/auth/verifyTotp.ts
index 3b30def9..185f3d1a 100644
--- a/server/routers/auth/verifyTotp.ts
+++ b/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(res, {
data: {
valid,
diff --git a/src/app/[orgId]/layout.tsx b/src/app/[orgId]/layout.tsx
index cc0d59be..55b83bac 100644
--- a/src/app/[orgId]/layout.tsx
+++ b/src/app/[orgId]/layout.tsx
@@ -30,8 +30,8 @@ export default async function OrgLayout(props: {
const getOrgUser = cache(() =>
internal.get>(
`/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>(
- `/org/${orgId}`,
- cookie,
- ),
+ internal.get>(`/org/${orgId}`, cookie)
);
await getOrg();
} catch {
diff --git a/src/app/[orgId]/settings/layout.tsx b/src/app/[orgId]/settings/layout.tsx
index eae156d1..c1793a5e 100644
--- a/src/app/[orgId]/settings/layout.tsx
+++ b/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) {
- {children}
+
+ {children}
+
>
);
}
diff --git a/src/app/[orgId]/settings/share-links/components/CreateShareLinkForm.tsx b/src/app/[orgId]/settings/share-links/components/CreateShareLinkForm.tsx
index 475fb1a0..f0e0f9c1 100644
--- a/src/app/[orgId]/settings/share-links/components/CreateShareLinkForm.tsx
+++ b/src/app/[orgId]/settings/share-links/components/CreateShareLinkForm.tsx
@@ -226,7 +226,7 @@ export default function CreateShareLinkForm({
>
- Create Sharable Link
+ Create Shareable Link
Anyone with this link can access the resource
@@ -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.
diff --git a/src/app/[orgId]/settings/sites/components/SitesTable.tsx b/src/app/[orgId]/settings/sites/components/SitesTable.tsx
index a36a0723..f31873f9 100644
--- a/src/app/[orgId]/settings/sites/components/SitesTable.tsx
+++ b/src/app/[orgId]/settings/sites/components/SitesTable.tsx
@@ -195,14 +195,14 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
if (originalRow.online) {
return (
-
+
Online
);
} else {
return (
-
-
+
+
Offline
);
diff --git a/src/app/profile/account/account-form.tsx b/src/app/profile/account/account-form.tsx
deleted file mode 100644
index 25b22370..00000000
--- a/src/app/profile/account/account-form.tsx
+++ /dev/null
@@ -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
-
-// This can come from your database or API.
-const defaultValues: Partial = {
- // name: "Your name",
- // dob: new Date("2023-01-23"),
-}
-
-export function AccountForm() {
- const form = useForm({
- resolver: zodResolver(accountFormSchema),
- defaultValues,
- })
-
- function onSubmit(data: AccountFormValues) {
- toast({
- title: "You submitted the following values:",
- description: (
-
- {JSON.stringify(data, null, 2)}
-
- ),
- })
- }
-
- return (
-
-
- )
-}
diff --git a/src/app/profile/account/page.tsx b/src/app/profile/account/page.tsx
deleted file mode 100644
index 2bc9ce78..00000000
--- a/src/app/profile/account/page.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-import { Separator } from "@/components/ui/separator"
-import { AccountForm } from "./account-form"
-
-export default function SettingsAccountPage() {
- return (
-
-
-
Account
-
- Update your account settings. Set your preferred language and
- timezone.
-
-
-
-
-
- )
-}
diff --git a/src/app/profile/appearance/appearance-form.tsx b/src/app/profile/appearance/appearance-form.tsx
deleted file mode 100644
index eaf67837..00000000
--- a/src/app/profile/appearance/appearance-form.tsx
+++ /dev/null
@@ -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
-
-// This can come from your database or API.
-const defaultValues: Partial = {
- theme: "light",
-}
-
-export function AppearanceForm() {
- const form = useForm({
- resolver: zodResolver(appearanceFormSchema),
- defaultValues,
- })
-
- function onSubmit(data: AppearanceFormValues) {
- toast({
- title: "You submitted the following values:",
- description: (
-
- {JSON.stringify(data, null, 2)}
-
- ),
- })
- }
-
- return (
-
-
- )
-}
diff --git a/src/app/profile/appearance/page.tsx b/src/app/profile/appearance/page.tsx
deleted file mode 100644
index 1c7e6652..00000000
--- a/src/app/profile/appearance/page.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-import { Separator } from "@/components/ui/separator"
-import { AppearanceForm } from "./appearance-form"
-
-export default function SettingsAppearancePage() {
- return (
-
-
-
Appearance
-
- Customize the appearance of the app. Automatically switch between day
- and night themes.
-
-
-
-
-
- )
-}
diff --git a/src/app/profile/display/display-form.tsx b/src/app/profile/display/display-form.tsx
deleted file mode 100644
index 058377c2..00000000
--- a/src/app/profile/display/display-form.tsx
+++ /dev/null
@@ -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
-
-// This can come from your database or API.
-const defaultValues: Partial = {
- items: ["recents", "home"],
-}
-
-export function DisplayForm() {
- const form = useForm({
- resolver: zodResolver(displayFormSchema),
- defaultValues,
- })
-
- function onSubmit(data: DisplayFormValues) {
- toast({
- title: "You submitted the following values:",
- description: (
-
- {JSON.stringify(data, null, 2)}
-
- ),
- })
- }
-
- return (
-
-
- )
-}
diff --git a/src/app/profile/display/page.tsx b/src/app/profile/display/page.tsx
deleted file mode 100644
index 55ac00e3..00000000
--- a/src/app/profile/display/page.tsx
+++ /dev/null
@@ -1,17 +0,0 @@
-import { Separator } from "@/components/ui/separator"
-import { DisplayForm } from "./display-form"
-
-export default function SettingsDisplayPage() {
- return (
-
-
-
Display
-
- Turn items on or off to control what's displayed in the app.
-
-
-
-
-
- )
-}
diff --git a/src/app/profile/general/layout_.tsx b/src/app/profile/general/layout_.tsx
new file mode 100644
index 00000000..947b3338
--- /dev/null
+++ b/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 (
+ <>
+
+ {children}
+
+ >
+ );
+}
diff --git a/src/app/profile/general/page_.tsx b/src/app/profile/general/page_.tsx
new file mode 100644
index 00000000..26ab15fc
--- /dev/null
+++ b/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 (
+ <>
+
+ >
+ );
+}
diff --git a/src/app/profile/layout.tsx b/src/app/profile/layout.tsx
deleted file mode 100644
index f7fc72d1..00000000
--- a/src/app/profile/layout.tsx
+++ /dev/null
@@ -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 (
- <>
-
-
-
-
-
-
-
Settings
-
- Manage your account settings and set e-mail preferences.
-
-
-
-
-
- >
- )
-}
diff --git a/src/app/profile/layout_.tsx b/src/app/profile/layout_.tsx
new file mode 100644
index 00000000..f2d73776
--- /dev/null
+++ b/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:
+ }
+];
+
+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>(`/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 (
+ <>
+
+
+
+ {children}
+
+ >
+ );
+}
diff --git a/src/app/profile/notifications/notifications-form.tsx b/src/app/profile/notifications/notifications-form.tsx
deleted file mode 100644
index a5e8ab8c..00000000
--- a/src/app/profile/notifications/notifications-form.tsx
+++ /dev/null
@@ -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
-
-// This can come from your database or API.
-const defaultValues: Partial = {
- communication_emails: false,
- marketing_emails: false,
- social_emails: true,
- security_emails: true,
-}
-
-export function NotificationsForm() {
- const form = useForm({
- resolver: zodResolver(notificationsFormSchema),
- defaultValues,
- })
-
- function onSubmit(data: NotificationsFormValues) {
- toast({
- title: "You submitted the following values:",
- description: (
-
- {JSON.stringify(data, null, 2)}
-
- ),
- })
- }
-
- return (
-
-
- )
-}
diff --git a/src/app/profile/notifications/page.tsx b/src/app/profile/notifications/page.tsx
deleted file mode 100644
index 0917cd4f..00000000
--- a/src/app/profile/notifications/page.tsx
+++ /dev/null
@@ -1,17 +0,0 @@
-import { Separator } from "@/components/ui/separator"
-import { NotificationsForm } from "./notifications-form"
-
-export default function SettingsNotificationsPage() {
- return (
-
-
-
Notifications
-
- Configure how you receive notifications.
-
-
-
-
-
- )
-}
diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx
deleted file mode 100644
index f68812c0..00000000
--- a/src/app/profile/page.tsx
+++ /dev/null
@@ -1,17 +0,0 @@
-import { Separator } from "@/components/ui/separator"
-import { ProfileForm } from "@app/components/profile-form"
-
-export default function SettingsProfilePage() {
- return (
-
-
-
Profile
-
- This is how others will see you on the site.
-
-
-
-
-
- )
-}
diff --git a/src/app/profile/page_.tsx b/src/app/profile/page_.tsx
new file mode 100644
index 00000000..f1dafa49
--- /dev/null
+++ b/src/app/profile/page_.tsx
@@ -0,0 +1,5 @@
+import { redirect } from "next/navigation";
+
+export default async function ProfilePage() {
+ redirect("/profile/general");
+}
diff --git a/src/app/profile/profile-form.tsx b/src/app/profile/profile-form.tsx
deleted file mode 100644
index a5e9749c..00000000
--- a/src/app/profile/profile-form.tsx
+++ /dev/null
@@ -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
-
-// This can come from your database or API.
-const defaultValues: Partial = {
- bio: "I own a computer.",
- urls: [
- { value: "https://shadcn.com" },
- { value: "http://twitter.com/shadcn" },
- ],
-}
-
-export function ProfileForm() {
- const form = useForm({
- 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: (
-
- {JSON.stringify(data, null, 2)}
-
- ),
- })
- }
-
- return (
-
-
- )
-}
diff --git a/src/components/Enable2FaForm.tsx b/src/components/Enable2FaForm.tsx
new file mode 100644
index 00000000..a5402322
--- /dev/null
+++ b/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([]);
+
+ const { toast } = useToast();
+
+ const { user, updateUser } = userUserContext();
+
+ const api = createApiClient(useEnvContext());
+
+ const enableForm = useForm>({
+ resolver: zodResolver(enableSchema),
+ defaultValues: {
+ password: ""
+ }
+ });
+
+ const confirmForm = useForm>({
+ resolver: zodResolver(confirmSchema),
+ defaultValues: {
+ code: ""
+ }
+ });
+
+ const request2fa = async (values: z.infer) => {
+ setLoading(true);
+
+ const res = await api
+ .post>(
+ `/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) => {
+ setLoading(true);
+
+ const res = await api
+ .post>(`/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 (
+ {
+ setOpen(val);
+ setLoading(false);
+ }}
+ >
+
+
+
+ Enable Two-factor Authentication
+
+
+ Secure your account with an extra layer of protection
+
+
+
+ {step === 1 && (
+
+
+
+ (
+
+ Password
+
+
+
+
+
+ )}
+ />
+
+
+
+ )}
+
+ {step === 2 && (
+
+
+ Scan this QR code with your authenticator app or
+ enter the secret key manually:
+
+
+
+
+
+
+
+
+
+
+
+ (
+
+
+ Verification Code
+
+
+
+
+
+
+ )}
+ />
+
+
+
+
+ )}
+
+ {step === 3 && (
+
+
+
+ Two-Factor Authentication Enabled
+
+
+ Your account is now more secure. Don't forget to
+ save your backup codes.
+
+
+
+
+
+
+ )}
+
+
+ {(step === 1 || step === 2) && (
+
+ Submit
+
+ )}
+
+ Close
+
+
+
+
+ );
+}
diff --git a/src/app/[orgId]/settings/components/Header.tsx b/src/components/Header.tsx
similarity index 56%
rename from src/app/[orgId]/settings/components/Header.tsx
rename to src/components/Header.tsx
index e114943a..87ba9e97 100644
--- a/src/app/[orgId]/settings/components/Header.tsx
+++ b/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 (
<>
+
+
@@ -128,15 +120,23 @@ export default function Header({ email, orgId, name, orgs }: HeaderProps) {
Signed in as
- {email}
+ {user.email}
-
-
- User Settings
-
+ {!user.twoFactorEnabled && (
+
setOpenEnable2fa(true)}
+ >
+ Enable Two-factor
+
+ )}
+ {user.twoFactorEnabled && (
+
+ Disable Two-factor
+
+ )}
Theme
{(["light", "dark", "system"] as const).map(
@@ -175,7 +175,7 @@ export default function Header({ email, orgId, name, orgs }: HeaderProps) {
- {name || email}
+ {user.email}
@@ -197,82 +197,88 @@ export default function Header({ email, orgId, name, orgs }: HeaderProps) {
-
-
-
-
-
-
- Organization
-
-
- {orgId
- ? orgs.find(
- (org) =>
- org.orgId === orgId
- )?.name
- : "Select organization..."}
-
+ {orgs && (
+
+
+
+
+
+
+ Organization
+
+
+ {orgId
+ ? orgs?.find(
+ (org) =>
+ org.orgId ===
+ orgId
+ )?.name
+ : "None selected"}
+
+
+
-
-
-
-
-
-
-
-
- No organizations found.
-
-
-
- {
- router.push("/setup");
- }}
- >
-
- New Organization
-
-
-
-
-
-
- {orgs.map((org) => (
+
+
+
+
+
+
+ No organizations found.
+
+
+
{
- router.push(
- `/${org.orgId}/settings`
- );
+ router.push("/setup");
}}
>
-
- {org.name}
+
+ New Organization
- ))}
-
-
-
-
-
+
+
+
+
+
+ {orgs.map((org) => (
+ {
+ router.push(
+ `/${org.orgId}/settings`
+ );
+ }}
+ >
+
+ {org.name}
+
+ ))}
+
+
+
+
+
+ )}
>
);
}
+
+export default Header;
diff --git a/src/components/LoginForm.tsx b/src/components/LoginForm.tsx
index d0dddd29..91b9315a 100644
--- a/src/components/LoginForm.tsx
+++ b/src/components/LoginForm.tsx
@@ -214,7 +214,13 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
Authenticator Code
-
+
diff --git a/src/app/[orgId]/settings/components/TopbarNav.tsx b/src/components/TopbarNav.tsx
similarity index 94%
rename from src/app/[orgId]/settings/components/TopbarNav.tsx
rename to src/components/TopbarNav.tsx
index 2aaa9d1b..d208e976 100644
--- a/src/app/[orgId]/settings/components/TopbarNav.tsx
+++ b/src/components/TopbarNav.tsx
@@ -12,7 +12,7 @@ interface TopbarNavProps extends React.HTMLAttributes {
icon: React.ReactNode;
}[];
disabled?: boolean;
- orgId: string;
+ orgId?: string;
}
export function TopbarNav({
@@ -36,10 +36,10 @@ export function TopbarNav({
{items.map((item) => (
-
-// This can come from your database or API.
-const defaultValues: Partial = {
- // name: "Your name",
- // dob: new Date("2023-01-23"),
-}
-
-export function AccountForm() {
- const form = useForm({
- resolver: zodResolver(accountFormSchema),
- defaultValues,
- })
-
- function onSubmit(data: AccountFormValues) {
- toast({
- title: "You submitted the following values:",
- description: (
-
- {JSON.stringify(data, null, 2)}
-
- ),
- })
- }
-
- return (
-
-
- (
-
- Name
-
-
-
-
- This is the name that will be displayed on your profile and in
- emails.
-
-
-
- )}
- />
- (
-
- Language
-
-
-
-
- {field.value
- ? languages.find(
- (language) => language.value === field.value
- )?.label
- : "Select language"}
-
-
-
-
-
-
-
-
- No language found.
-
- {languages.map((language) => (
- {
- form.setValue("language", language.value)
- }}
- >
-
- {language.label}
-
- ))}
-
-
-
-
-
-
- This is the language that will be used in the dashboard.
-
-
-
- )}
- />
- Update account
-
-
- )
-}
diff --git a/src/components/appearance-form.tsx b/src/components/appearance-form.tsx
deleted file mode 100644
index b50068ba..00000000
--- a/src/components/appearance-form.tsx
+++ /dev/null
@@ -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;
-
-// This can come from your database or API.
-const defaultValues: Partial = {
- theme: "light",
-};
-
-export function AppearanceForm() {
- const site = useSiteContext();
-
- console.log(site);
-
- const form = useForm({
- resolver: zodResolver(appearanceFormSchema),
- defaultValues,
- });
-
- function onSubmit(data: AppearanceFormValues) {
- toast({
- title: "You submitted the following values:",
- description: (
-
-
- {JSON.stringify(data, null, 2)}
-
-
- ),
- });
- }
-
- return (
-
-
- (
-
- Font
-
-
-
- Inter
- Manrope
- System
-
-
-
-
-
- Set the font you want to use in the dashboard.
-
-
-
- )}
- />
- (
-
- Theme
-
- Select the theme for the dashboard.
-
-
-
-
-
-
-
-
-
-
- Light
-
-
-
-
-
-
-
-
-
-
- Dark
-
-
-
-
-
- )}
- />
-
- Update preferences
-
-
- );
-}
diff --git a/src/components/display-form.tsx b/src/components/display-form.tsx
deleted file mode 100644
index 058377c2..00000000
--- a/src/components/display-form.tsx
+++ /dev/null
@@ -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
-
-// This can come from your database or API.
-const defaultValues: Partial = {
- items: ["recents", "home"],
-}
-
-export function DisplayForm() {
- const form = useForm({
- resolver: zodResolver(displayFormSchema),
- defaultValues,
- })
-
- function onSubmit(data: DisplayFormValues) {
- toast({
- title: "You submitted the following values:",
- description: (
-
- {JSON.stringify(data, null, 2)}
-
- ),
- })
- }
-
- return (
-
-
- (
-
-
- Sidebar
-
- Select the items you want to display in the sidebar.
-
-
- {items.map((item) => (
- {
- return (
-
-
- {
- return checked
- ? field.onChange([...field.value, item.id])
- : field.onChange(
- field.value?.filter(
- (value) => value !== item.id
- )
- )
- }}
- />
-
-
- {item.label}
-
-
- )
- }}
- />
- ))}
-
-
- )}
- />
- Update display
-
-
- )
-}
diff --git a/src/components/notifications-form.tsx b/src/components/notifications-form.tsx
deleted file mode 100644
index a5e8ab8c..00000000
--- a/src/components/notifications-form.tsx
+++ /dev/null
@@ -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
-
-// This can come from your database or API.
-const defaultValues: Partial = {
- communication_emails: false,
- marketing_emails: false,
- social_emails: true,
- security_emails: true,
-}
-
-export function NotificationsForm() {
- const form = useForm({
- resolver: zodResolver(notificationsFormSchema),
- defaultValues,
- })
-
- function onSubmit(data: NotificationsFormValues) {
- toast({
- title: "You submitted the following values:",
- description: (
-
- {JSON.stringify(data, null, 2)}
-
- ),
- })
- }
-
- return (
-
-
- (
-
- Notify me about...
-
-
-
-
-
-
-
- All new messages
-
-
-
-
-
-
-
- Direct messages and mentions
-
-
-
-
-
-
- Nothing
-
-
-
-
-
- )}
- />
-
-
Email Notifications
-
-
(
-
-
-
- Communication emails
-
-
- Receive emails about your account activity.
-
-
-
-
-
-
- )}
- />
- (
-
-
-
- Marketing emails
-
-
- Receive emails about new products, features, and more.
-
-
-
-
-
-
- )}
- />
- (
-
-
- Social emails
-
- Receive emails for friend requests, follows, and more.
-
-
-
-
-
-
- )}
- />
- (
-
-
- Security emails
-
- Receive emails about your account activity and security.
-
-
-
-
-
-
- )}
- />
-
-
- (
-
-
-
-
-
-
- Use different settings for my mobile devices
-
-
- You can manage your mobile notifications in the{" "}
- mobile settings page.
-
-
-
- )}
- />
- Update notifications
-
-
- )
-}
diff --git a/src/components/profile-form.tsx b/src/components/profile-form.tsx
deleted file mode 100644
index a5e9749c..00000000
--- a/src/components/profile-form.tsx
+++ /dev/null
@@ -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
-
-// This can come from your database or API.
-const defaultValues: Partial = {
- bio: "I own a computer.",
- urls: [
- { value: "https://shadcn.com" },
- { value: "http://twitter.com/shadcn" },
- ],
-}
-
-export function ProfileForm() {
- const form = useForm({
- 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: (
-
- {JSON.stringify(data, null, 2)}
-
- ),
- })
- }
-
- return (
-
-
- (
-
- Username
-
-
-
-
- This is your public display name. It can be your real name or a
- pseudonym. You can only change this once every 30 days.
-
-
-
- )}
- />
- (
-
- Email
-
-
-
-
-
-
-
- m@example.com
- m@google.com
- m@support.com
-
-
-
- You can manage verified email addresses in your{" "}
- email settings.
-
-
-
- )}
- />
- (
-
- Bio
-
-
-
-
- You can @mention other users and organizations to
- link to them.
-
-
-
- )}
- />
-
- {fields.map((field, index) => (
- (
-
-
- URLs
-
-
- Add links to your website, blog, or social media profiles.
-
-
-
-
-
-
- )}
- />
- ))}
- append({ value: "" })}
- >
- Add URL
-
-
- Update profile
-
-
- )
-}
diff --git a/src/contexts/userContext.ts b/src/contexts/userContext.ts
index 97389c1c..1a062ef8 100644
--- a/src/contexts/userContext.ts
+++ b/src/contexts/userContext.ts
@@ -1,4 +1,11 @@
import { GetUserResponse } from "@server/routers/user";
import { createContext } from "react";
-export const UserContext = createContext(null);
+interface UserContextType {
+ user: GetUserResponse;
+ updateUser: (updatedUser: Partial) => void;
+}
+
+const UserContext = createContext(undefined);
+
+export default UserContext;
diff --git a/src/hooks/useUserContext.ts b/src/hooks/useUserContext.ts
index 8d9e011c..cf90217d 100644
--- a/src/hooks/useUserContext.ts
+++ b/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;
}
diff --git a/src/providers/UserProvider.tsx b/src/providers/UserProvider.tsx
index 47950725..faa37fa7 100644
--- a/src/providers/UserProvider.tsx
+++ b/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({ user, children }: UserProviderProps) {
- return {children} ;
+export function UserProvider({ children, user: u }: UserProviderProps) {
+ const [user, setUser] = useState(u);
+
+ const updateUser = (updatedUser: Partial) => {
+ if (!user) {
+ throw new Error("No user to update");
+ }
+ setUser((prev) => {
+ if (!prev) {
+ return prev;
+ }
+ return {
+ ...prev,
+ ...updatedUser
+ };
+ });
+ };
+
+ return (
+
+ {children}
+
+ );
}
export default UserProvider;