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 ( -
- - ( - - Name - - - - - This is the name that will be displayed on your profile and in - emails. - - - - )} - /> - ( - - 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. - - - - )} - /> - - - - ) -} 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 ( -
- - ( - - Font -
- - - - -
- - Set the font you want to use in the dashboard. - - -
- )} - /> - ( - - Theme - - Select the theme for the dashboard. - - - - - - - - -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- - Light - - - - - - - - -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- - Dark - - - - - - )} - /> - - - - - ) -} 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 ( -
- - ( - -
- 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} - - - ) - }} - /> - ))} - -
- )} - /> - - - - ) -} 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 ( - <> -
- Forms - Forms -
-
-
-

Settings

-

- Manage your account settings and set e-mail preferences. -

-
- -
- -
{children}
-
-
- - ) -} 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 ( -
- - ( - - 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. - -
-
- )} - /> - - - - ) -} 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 ( -
- - ( - - 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 - - - You can manage verified email addresses in your{" "} - email settings. - - - - )} - /> - ( - - Bio - -