enable 2fa flow

This commit is contained in:
Milo Schwartz 2024-12-23 23:59:15 -05:00
parent b1afba191e
commit 9e50a580a5
No known key found for this signature in database
33 changed files with 605 additions and 2084 deletions

View file

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

View file

@ -92,6 +92,15 @@ export async function verifyTotp(
// TODO: send email to user confirming two-factor authentication is enabled // 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, { return response<VerifyTotpResponse>(res, {
data: { data: {
valid, valid,

View file

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

View file

@ -1,7 +1,7 @@
import { Metadata } from "next"; 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 { 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 { verifySession } from "@app/lib/auth/verifySession";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { internal } from "@app/api"; import { internal } from "@app/api";
@ -10,6 +10,7 @@ import { GetOrgResponse, ListOrgsResponse } from "@server/routers/org";
import { authCookieHeader } from "@app/api/cookies"; import { authCookieHeader } from "@app/api/cookies";
import { cache } from "react"; import { cache } from "react";
import { GetOrgUserResponse } from "@server/routers/user"; import { GetOrgUserResponse } from "@server/routers/user";
import UserProvider from "@app/providers/UserProvider";
export const dynamic = "force-dynamic"; 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="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="container mx-auto flex flex-col content-between">
<div className="my-4"> <div className="my-4">
<Header <UserProvider user={user}>
email={user.email} <Header orgId={params.orgId} orgs={orgs} />
orgId={params.orgId} </UserProvider>
orgs={orgs}
/>
</div> </div>
<TopbarNav items={topNavItems} orgId={params.orgId} /> <TopbarNav items={topNavItems} orgId={params.orgId} />
</div> </div>
</div> </div>
<div className="container mx-auto sm:px-0 px-3 pt-[165px]">{children}</div> <div className="container mx-auto sm:px-0 px-3 pt-[165px]">
{children}
</div>
</> </>
); );
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -15,7 +15,6 @@ import {
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuSeparator, DropdownMenuSeparator,
@ -26,14 +25,6 @@ import {
PopoverContent, PopoverContent,
PopoverTrigger PopoverTrigger
} from "@app/components/ui/popover"; } from "@app/components/ui/popover";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue
} from "@app/components/ui/select";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { useToast } from "@app/hooks/useToast"; import { useToast } from "@app/hooks/useToast";
import { cn, formatAxiosError } from "@app/lib/utils"; import { cn, formatAxiosError } from "@app/lib/utils";
@ -45,40 +36,39 @@ import {
LogOut, LogOut,
Moon, Moon,
Plus, Plus,
Sun, Sun
User
} from "lucide-react"; } from "lucide-react";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import Enable2FaForm from "./Enable2FaForm";
import { userUserContext } from "@app/hooks/useUserContext";
type HeaderProps = { type HeaderProps = {
name?: string; orgId?: string;
email: 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 { toast } = useToast();
const { setTheme, theme } = useTheme(); const { setTheme, theme } = useTheme();
const { user, updateUser } = userUserContext();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [userTheme, setUserTheme] = useState<"light" | "dark" | "system">( const [userTheme, setUserTheme] = useState<"light" | "dark" | "system">(
theme as "light" | "dark" | "system" theme as "light" | "dark" | "system"
); );
const [openEnable2fa, setOpenEnable2fa] = useState(false);
const router = useRouter(); const router = useRouter();
const api = createApiClient(useEnvContext()); const api = createApiClient(useEnvContext());
function getInitials() { function getInitials() {
if (name) { return user.email.substring(0, 2).toUpperCase();
const [firstName, lastName] = name.split(" ");
return `${firstName[0]}${lastName[0]}`;
}
return email.substring(0, 2).toUpperCase();
} }
function logout() { function logout() {
@ -102,6 +92,8 @@ export default function Header({ email, orgId, name, orgs }: HeaderProps) {
return ( return (
<> <>
<Enable2FaForm open={openEnable2fa} setOpen={setOpenEnable2fa} />
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<DropdownMenu> <DropdownMenu>
@ -128,15 +120,23 @@ export default function Header({ email, orgId, name, orgs }: HeaderProps) {
Signed in as Signed in as
</p> </p>
<p className="text-xs leading-none text-muted-foreground"> <p className="text-xs leading-none text-muted-foreground">
{email} {user.email}
</p> </p>
</div> </div>
</DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem> {!user.twoFactorEnabled && (
<User className="mr-2 h-4 w-4" /> <DropdownMenuItem
<span>User Settings</span> onClick={() => setOpenEnable2fa(true)}
>
<span>Enable Two-factor</span>
</DropdownMenuItem> </DropdownMenuItem>
)}
{user.twoFactorEnabled && (
<DropdownMenuItem>
<span>Disable Two-factor</span>
</DropdownMenuItem>
)}
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuLabel>Theme</DropdownMenuLabel> <DropdownMenuLabel>Theme</DropdownMenuLabel>
{(["light", "dark", "system"] as const).map( {(["light", "dark", "system"] as const).map(
@ -175,7 +175,7 @@ export default function Header({ email, orgId, name, orgs }: HeaderProps) {
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<span className="truncate max-w-[150px] md:max-w-none font-medium"> <span className="truncate max-w-[150px] md:max-w-none font-medium">
{name || email} {user.email}
</span> </span>
</div> </div>
@ -197,6 +197,7 @@ export default function Header({ email, orgId, name, orgs }: HeaderProps) {
</div> </div>
</div> </div>
{orgs && (
<Popover open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
@ -213,11 +214,12 @@ export default function Header({ email, orgId, name, orgs }: HeaderProps) {
</span> </span>
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
{orgId {orgId
? orgs.find( ? orgs?.find(
(org) => (org) =>
org.orgId === orgId org.orgId ===
orgId
)?.name )?.name
: "Select organization..."} : "None selected"}
</span> </span>
</div> </div>
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" /> <ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
@ -233,7 +235,6 @@ export default function Header({ email, orgId, name, orgs }: HeaderProps) {
<CommandGroup heading="Create"> <CommandGroup heading="Create">
<CommandList> <CommandList>
<CommandItem <CommandItem
className="flex items-center cursor-pointer"
onSelect={(currentValue) => { onSelect={(currentValue) => {
router.push("/setup"); router.push("/setup");
}} }}
@ -249,7 +250,9 @@ export default function Header({ email, orgId, name, orgs }: HeaderProps) {
{orgs.map((org) => ( {orgs.map((org) => (
<CommandItem <CommandItem
key={org.orgId} key={org.orgId}
onSelect={(currentValue) => { onSelect={(
currentValue
) => {
router.push( router.push(
`/${org.orgId}/settings` `/${org.orgId}/settings`
); );
@ -271,8 +274,11 @@ export default function Header({ email, orgId, name, orgs }: HeaderProps) {
</Command> </Command>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
)}
</div> </div>
</div> </div>
</> </>
); );
} }
export default Header;

View file

@ -214,7 +214,13 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
<FormLabel>Authenticator Code</FormLabel> <FormLabel>Authenticator Code</FormLabel>
<FormControl> <FormControl>
<div className="flex justify-center"> <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> <InputOTPGroup>
<InputOTPSlot index={0} /> <InputOTPSlot index={0} />
<InputOTPSlot index={1} /> <InputOTPSlot index={1} />

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,4 +1,11 @@
import { GetUserResponse } from "@server/routers/user"; import { GetUserResponse } from "@server/routers/user";
import { createContext } from "react"; 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;

View file

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

View file

@ -1,16 +1,37 @@
"use client"; "use client";
import { UserContext } from "@app/contexts/userContext"; import UserContext from "@app/contexts/userContext";
import { GetUserResponse } from "@server/routers/user"; import { GetUserResponse } from "@server/routers/user";
import { ReactNode } from "react"; import { useState } from "react";
type UserProviderProps = { interface UserProviderProps {
children: React.ReactNode;
user: GetUserResponse; user: GetUserResponse;
children: ReactNode; }
};
export function UserProvider({ user, children }: UserProviderProps) { export function UserProvider({ children, user: u }: UserProviderProps) {
return <UserContext.Provider value={user}>{children}</UserContext.Provider>; 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
};
});
};
return (
<UserContext.Provider value={{ user: user, updateUser: updateUser }}>
{children}
</UserContext.Provider>
);
} }
export default UserProvider; export default UserProvider;