mirror of
https://github.com/fosrl/pangolin.git
synced 2025-07-01 09:34:54 +02:00
enable 2fa flow
This commit is contained in:
parent
b1afba191e
commit
9e50a580a5
33 changed files with 605 additions and 2084 deletions
|
@ -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",
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -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's displayed in the app.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Separator />
|
|
||||||
<DisplayForm />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
36
src/app/profile/general/layout_.tsx
Normal file
36
src/app/profile/general/layout_.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
14
src/app/profile/general/page_.tsx
Normal file
14
src/app/profile/general/page_.tsx
Normal 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} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,76 +0,0 @@
|
||||||
import { Metadata } from "next"
|
|
||||||
import Image from "next/image"
|
|
||||||
|
|
||||||
import { Separator } from "@/components/ui/separator"
|
|
||||||
import { SidebarNav } from "@/components/sidebar-nav"
|
|
||||||
import Header from "../[orgId]/settings/components/Header"
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: "Forms",
|
|
||||||
description: "Advanced form example using react-hook-form and Zod.",
|
|
||||||
}
|
|
||||||
|
|
||||||
const sidebarNavItems = [
|
|
||||||
{
|
|
||||||
title: "Profile",
|
|
||||||
href: "/configuration",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Account",
|
|
||||||
href: "/configuration/account",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Appearance",
|
|
||||||
href: "/configuration/appearance",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Notifications",
|
|
||||||
href: "/configuration/notifications",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Display",
|
|
||||||
href: "/configuration/display",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
interface SettingsLayoutProps {
|
|
||||||
children: React.ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SettingsLayout({ children }: SettingsLayoutProps) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="md:hidden">
|
|
||||||
<Image
|
|
||||||
src="/configuration/forms-light.png"
|
|
||||||
width={1280}
|
|
||||||
height={791}
|
|
||||||
alt="Forms"
|
|
||||||
className="block dark:hidden"
|
|
||||||
/>
|
|
||||||
<Image
|
|
||||||
src="/configuration/forms-dark.png"
|
|
||||||
width={1280}
|
|
||||||
height={791}
|
|
||||||
alt="Forms"
|
|
||||||
className="hidden dark:block"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="hidden space-y-8 p-10 pb-16 md:block">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<h2 className="text-2xl font-bold tracking-tight">Settings</h2>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Manage your account settings and set e-mail preferences.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Separator className="my-6" />
|
|
||||||
<div className="flex flex-col space-y-4 lg:flex-row lg:space-x-12 lg:space-y-0">
|
|
||||||
<aside className="-mx-4 lg:w-1/5">
|
|
||||||
<SidebarNav items={sidebarNavItems} />
|
|
||||||
</aside>
|
|
||||||
<div className="flex-1 lg:max-w-2xl">{children}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
74
src/app/profile/layout_.tsx
Normal file
74
src/app/profile/layout_.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,17 +0,0 @@
|
||||||
import { Separator } from "@/components/ui/separator"
|
|
||||||
import { ProfileForm } from "@app/components/profile-form"
|
|
||||||
|
|
||||||
export default function SettingsProfilePage() {
|
|
||||||
return (
|
|
||||||
<div className="space-y-8">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-medium">Profile</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
This is how others will see you on the site.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Separator />
|
|
||||||
<ProfileForm />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
5
src/app/profile/page_.tsx
Normal file
5
src/app/profile/page_.tsx
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default async function ProfilePage() {
|
||||||
|
redirect("/profile/general");
|
||||||
|
}
|
|
@ -1,192 +0,0 @@
|
||||||
"use client"
|
|
||||||
|
|
||||||
import Link from "next/link"
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
|
||||||
import { useFieldArray, useForm } from "react-hook-form"
|
|
||||||
import { z } from "zod"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { toast } from "@/hooks/useToast"
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormDescription,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from "@/components/ui/form"
|
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select"
|
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
|
||||||
|
|
||||||
const profileFormSchema = z.object({
|
|
||||||
username: z
|
|
||||||
.string()
|
|
||||||
.min(2, {
|
|
||||||
message: "Username must be at least 2 characters.",
|
|
||||||
})
|
|
||||||
.max(30, {
|
|
||||||
message: "Username must not be longer than 30 characters.",
|
|
||||||
}),
|
|
||||||
email: z
|
|
||||||
.string({
|
|
||||||
required_error: "Please select an email to display.",
|
|
||||||
})
|
|
||||||
.email(),
|
|
||||||
bio: z.string().max(160).min(4),
|
|
||||||
urls: z
|
|
||||||
.array(
|
|
||||||
z.object({
|
|
||||||
value: z.string().url({ message: "Please enter a valid URL." }),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.optional(),
|
|
||||||
})
|
|
||||||
|
|
||||||
type ProfileFormValues = z.infer<typeof profileFormSchema>
|
|
||||||
|
|
||||||
// This can come from your database or API.
|
|
||||||
const defaultValues: Partial<ProfileFormValues> = {
|
|
||||||
bio: "I own a computer.",
|
|
||||||
urls: [
|
|
||||||
{ value: "https://shadcn.com" },
|
|
||||||
{ value: "http://twitter.com/shadcn" },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ProfileForm() {
|
|
||||||
const form = useForm<ProfileFormValues>({
|
|
||||||
resolver: zodResolver(profileFormSchema),
|
|
||||||
defaultValues,
|
|
||||||
mode: "onChange",
|
|
||||||
})
|
|
||||||
|
|
||||||
const { fields, append } = useFieldArray({
|
|
||||||
name: "urls",
|
|
||||||
control: form.control,
|
|
||||||
})
|
|
||||||
|
|
||||||
function onSubmit(data: ProfileFormValues) {
|
|
||||||
toast({
|
|
||||||
title: "You submitted the following values:",
|
|
||||||
description: (
|
|
||||||
<pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
|
|
||||||
<code className="text-white">{JSON.stringify(data, null, 2)}</code>
|
|
||||||
</pre>
|
|
||||||
),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="username"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Username</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="shadcn" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
This is your public display name. It can be your real name or a
|
|
||||||
pseudonym. You can only change this once every 30 days.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="email"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Email</FormLabel>
|
|
||||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
|
||||||
<FormControl>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select a verified email to display" />
|
|
||||||
</SelectTrigger>
|
|
||||||
</FormControl>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="m@example.com">m@example.com</SelectItem>
|
|
||||||
<SelectItem value="m@google.com">m@google.com</SelectItem>
|
|
||||||
<SelectItem value="m@support.com">m@support.com</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FormDescription>
|
|
||||||
You can manage verified email addresses in your{" "}
|
|
||||||
<Link href="/examples/forms">email settings</Link>.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="bio"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Bio</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Textarea
|
|
||||||
placeholder="Tell us a little bit about yourself"
|
|
||||||
className="resize-none"
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
You can <span>@mention</span> other users and organizations to
|
|
||||||
link to them.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
{fields.map((field, index) => (
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
key={field.id}
|
|
||||||
name={`urls.${index}.value`}
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel className={cn(index !== 0 && "sr-only")}>
|
|
||||||
URLs
|
|
||||||
</FormLabel>
|
|
||||||
<FormDescription className={cn(index !== 0 && "sr-only")}>
|
|
||||||
Add links to your website, blog, or social media profiles.
|
|
||||||
</FormDescription>
|
|
||||||
<FormControl>
|
|
||||||
<Input {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="mt-2"
|
|
||||||
onClick={() => append({ value: "" })}
|
|
||||||
>
|
|
||||||
Add URL
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<Button type="submit">Update profile</Button>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
)
|
|
||||||
}
|
|
291
src/components/Enable2FaForm.tsx
Normal file
291
src/components/Enable2FaForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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)}
|
||||||
</DropdownMenuItem>
|
>
|
||||||
|
<span>Enable Two-factor</span>
|
||||||
|
</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,82 +197,88 @@ export default function Header({ email, orgId, name, orgs }: HeaderProps) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
{orgs && (
|
||||||
<PopoverTrigger asChild>
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
<Button
|
<PopoverTrigger asChild>
|
||||||
variant="outline"
|
<Button
|
||||||
size="lg"
|
variant="outline"
|
||||||
role="combobox"
|
size="lg"
|
||||||
aria-expanded={open}
|
role="combobox"
|
||||||
className="w-full md:w-[200px] h-12 px-3 py-4 bg-neutral hover:bg-neutral"
|
aria-expanded={open}
|
||||||
>
|
className="w-full md:w-[200px] h-12 px-3 py-4 bg-neutral hover:bg-neutral"
|
||||||
<div className="flex items-center justify-between w-full">
|
>
|
||||||
<div className="flex flex-col items-start">
|
<div className="flex items-center justify-between w-full">
|
||||||
<span className="font-bold text-sm">
|
<div className="flex flex-col items-start">
|
||||||
Organization
|
<span className="font-bold text-sm">
|
||||||
</span>
|
Organization
|
||||||
<span className="text-sm text-muted-foreground">
|
</span>
|
||||||
{orgId
|
<span className="text-sm text-muted-foreground">
|
||||||
? orgs.find(
|
{orgId
|
||||||
(org) =>
|
? orgs?.find(
|
||||||
org.orgId === orgId
|
(org) =>
|
||||||
)?.name
|
org.orgId ===
|
||||||
: "Select organization..."}
|
orgId
|
||||||
</span>
|
)?.name
|
||||||
|
: "None selected"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
|
||||||
</div>
|
</div>
|
||||||
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
|
</Button>
|
||||||
</div>
|
</PopoverTrigger>
|
||||||
</Button>
|
<PopoverContent className="[100px] md:w-[180px] p-0">
|
||||||
</PopoverTrigger>
|
<Command>
|
||||||
<PopoverContent className="[100px] md:w-[180px] p-0">
|
<CommandInput placeholder="Search..." />
|
||||||
<Command>
|
<CommandEmpty>
|
||||||
<CommandInput placeholder="Search..." />
|
No organizations found.
|
||||||
<CommandEmpty>
|
</CommandEmpty>
|
||||||
No organizations found.
|
<CommandGroup heading="Create">
|
||||||
</CommandEmpty>
|
<CommandList>
|
||||||
<CommandGroup heading="Create">
|
|
||||||
<CommandList>
|
|
||||||
<CommandItem
|
|
||||||
className="flex items-center cursor-pointer"
|
|
||||||
onSelect={(currentValue) => {
|
|
||||||
router.push("/setup");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
New Organization
|
|
||||||
</CommandItem>
|
|
||||||
</CommandList>
|
|
||||||
</CommandGroup>
|
|
||||||
<CommandSeparator />
|
|
||||||
<CommandGroup heading="Organizations">
|
|
||||||
<CommandList>
|
|
||||||
{orgs.map((org) => (
|
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={org.orgId}
|
|
||||||
onSelect={(currentValue) => {
|
onSelect={(currentValue) => {
|
||||||
router.push(
|
router.push("/setup");
|
||||||
`/${org.orgId}/settings`
|
|
||||||
);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Check
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
className={cn(
|
New Organization
|
||||||
"mr-2 h-4 w-4",
|
|
||||||
orgId === org.orgId
|
|
||||||
? "opacity-100"
|
|
||||||
: "opacity-0"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{org.name}
|
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
))}
|
</CommandList>
|
||||||
</CommandList>
|
</CommandGroup>
|
||||||
</CommandGroup>
|
<CommandSeparator />
|
||||||
</Command>
|
<CommandGroup heading="Organizations">
|
||||||
</PopoverContent>
|
<CommandList>
|
||||||
</Popover>
|
{orgs.map((org) => (
|
||||||
|
<CommandItem
|
||||||
|
key={org.orgId}
|
||||||
|
onSelect={(
|
||||||
|
currentValue
|
||||||
|
) => {
|
||||||
|
router.push(
|
||||||
|
`/${org.orgId}/settings`
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
orgId === org.orgId
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{org.name}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandList>
|
||||||
|
</CommandGroup>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default Header;
|
|
@ -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} />
|
||||||
|
|
|
@ -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",
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue