Merge pull request #105 from MAGICGrants/mailing-address-save

Mailing address save
This commit is contained in:
Artur
2024-12-06 17:35:55 -03:00
committed by GitHub
10 changed files with 1289 additions and 156 deletions

View File

@@ -9,7 +9,15 @@ import { z } from 'zod'
import { StrapiPerkPopulated } from '../server/types'
import { env } from '../env.mjs'
import { Button } from './ui/button'
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from './ui/form'
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from './ui/form'
import { Input } from './ui/input'
import { toast } from './ui/use-toast'
import { useFundSlug } from '../utils/use-fund-slug'
@@ -36,6 +44,7 @@ import {
CarouselPrevious,
} from './ui/carousel'
import CustomLink from './CustomLink'
import { Checkbox } from './ui/checkbox'
type Props = { perk: StrapiPerkPopulated; balance: number; close: () => void }
@@ -43,7 +52,6 @@ const pointFormat = Intl.NumberFormat('en', { notation: 'standard', compactDispl
const schema = z
.object({
_shippingStateOptionsLength: z.number(),
shippingAddressLine1: z.string().min(1),
shippingAddressLine2: z.string(),
shippingCity: z.string().min(1),
@@ -56,6 +64,8 @@ const schema = z
.regex(/^\+?\d{6,15}$/, 'Invalid phone number.'),
shippingTaxNumber: z.string(),
printfulSyncVariantId: z.string().optional(),
_shippingStateOptionsLength: z.number(),
_useAccountMailingAddress: z.boolean(),
})
.superRefine((data, ctx) => {
const cpfRegex =
@@ -100,6 +110,7 @@ function PerkPurchaseFormModal({ perk, balance, close }: Props) {
const router = useRouter()
const fundSlug = useFundSlug()
const getCountriesQuery = trpc.perk.getCountries.useQuery()
const getUserAttributesQuery = trpc.account.getUserAttributes.useQuery()
const purchasePerkMutation = trpc.perk.purchasePerk.useMutation()
const estimatePrintfulOrderCosts = trpc.perk.estimatePrintfulOrderCosts.useMutation()
@@ -139,6 +150,7 @@ function PerkPurchaseFormModal({ perk, balance, close }: Props) {
const shippingCountry = form.watch('shippingCountry')
const shippingState = form.watch('shippingState')
const printfulSyncVariantId = form.watch('printfulSyncVariantId')
const useAccountMailingAddress = form.watch('_useAccountMailingAddress')
const shippingStateOptions = useMemo(() => {
const selectedCountry = (getCountriesQuery.data || []).find(
@@ -163,6 +175,26 @@ function PerkPurchaseFormModal({ perk, balance, close }: Props) {
form.setValue('_shippingStateOptionsLength', shippingStateOptions.length)
}, [shippingStateOptions])
useEffect(() => {
if (!getUserAttributesQuery.data) return
if (useAccountMailingAddress) {
form.setValue('shippingAddressLine1', getUserAttributesQuery.data.addressLine1)
form.setValue('shippingAddressLine2', getUserAttributesQuery.data.addressLine2)
form.setValue('shippingCountry', getUserAttributesQuery.data.addressCountry)
form.setValue('shippingCity', getUserAttributesQuery.data.addressCity)
form.setValue('shippingZip', getUserAttributesQuery.data.addressZip)
setTimeout(() => form.setValue('shippingState', getUserAttributesQuery.data.addressState), 20)
} else {
form.setValue('shippingAddressLine1', '')
form.setValue('shippingAddressLine2', '')
form.setValue('shippingCountry', '')
form.setValue('shippingState', '')
form.setValue('shippingCity', '')
form.setValue('shippingZip', '')
}
}, [useAccountMailingAddress])
async function onSubmit(data: PerkPurchaseInputs) {
if (!fundSlug) return
@@ -342,6 +374,23 @@ function PerkPurchaseFormModal({ perk, balance, close }: Props) {
{perk.needsShippingAddress && !getPrintfulProductVariantsQuery.data && <Spinner />}
{getUserAttributesQuery.data?.addressLine1 && (
<FormField
control={form.control}
name="_useAccountMailingAddress"
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 flex flex-col items-start leading-none">
<FormLabel>Use saved mailing address</FormLabel>
</div>
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="shippingAddressLine1"
@@ -349,7 +398,7 @@ function PerkPurchaseFormModal({ perk, balance, close }: Props) {
<FormItem>
<FormLabel>Address line 1 *</FormLabel>
<FormControl>
<Input {...field} />
<Input {...field} disabled={useAccountMailingAddress} />
</FormControl>
<FormMessage />
</FormItem>
@@ -363,7 +412,7 @@ function PerkPurchaseFormModal({ perk, balance, close }: Props) {
<FormItem>
<FormLabel>Address line 2</FormLabel>
<FormControl>
<Input {...field} />
<Input {...field} disabled={useAccountMailingAddress} />
</FormControl>
<FormMessage />
</FormItem>
@@ -381,23 +430,26 @@ function PerkPurchaseFormModal({ perk, balance, close }: Props) {
open={countrySelectOpen}
onOpenChange={(open) => setCountrySelectOpen(open)}
>
<PopoverTrigger>
<FormControl>
<Select
open={countrySelectOpen}
onValueChange={() => setCountrySelectOpen(false)}
>
<SelectTrigger>
<SelectValue
placeholder={
(getCountriesQuery.data || []).find(
(country) => country.code === shippingCountry
)?.name || ''
}
/>
</SelectTrigger>
</Select>
</FormControl>
<PopoverTrigger asChild>
<div>
<FormControl>
<Select
open={countrySelectOpen}
onValueChange={() => setCountrySelectOpen(false)}
disabled={useAccountMailingAddress}
>
<SelectTrigger>
<SelectValue
placeholder={
(getCountriesQuery.data || []).find(
(country) => country.code === shippingCountry
)?.name || ''
}
/>
</SelectTrigger>
</Select>
</FormControl>
</div>
</PopoverTrigger>
<PopoverContent className="p-0">
<Command>
@@ -447,20 +499,22 @@ function PerkPurchaseFormModal({ perk, balance, close }: Props) {
open={stateSelectOpen}
onOpenChange={(open) => setStateSelectOpen(open)}
>
<PopoverTrigger>
<FormControl>
<Select>
<SelectTrigger>
<SelectValue
placeholder={
shippingStateOptions.find(
(state) => state.value === shippingState
)?.label
}
/>
</SelectTrigger>
</Select>
</FormControl>
<PopoverTrigger asChild>
<div>
<FormControl>
<Select disabled={useAccountMailingAddress}>
<SelectTrigger>
<SelectValue
placeholder={
shippingStateOptions.find(
(state) => state.value === shippingState
)?.label
}
/>
</SelectTrigger>
</Select>
</FormControl>
</div>
</PopoverTrigger>
<PopoverContent className="p-0">
<Command>
@@ -506,7 +560,7 @@ function PerkPurchaseFormModal({ perk, balance, close }: Props) {
<FormItem>
<FormLabel>City *</FormLabel>
<FormControl>
<Input {...field} />
<Input {...field} disabled={useAccountMailingAddress} />
</FormControl>
<FormMessage />
</FormItem>
@@ -520,7 +574,7 @@ function PerkPurchaseFormModal({ perk, balance, close }: Props) {
<FormItem>
<FormLabel>Postal code *</FormLabel>
<FormControl>
<Input {...field} />
<Input {...field} disabled={useAccountMailingAddress} />
</FormControl>
<FormMessage />
</FormItem>

View File

@@ -1,3 +1,5 @@
import { useEffect, useMemo, useState } from 'react'
import { Check } from 'lucide-react'
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
@@ -25,6 +27,18 @@ import { useToast } from './ui/use-toast'
import { trpc } from '../utils/trpc'
import { useFundSlug } from '../utils/use-fund-slug'
import Spinner from './Spinner'
import { Popover, PopoverContent, PopoverTrigger } from './ui/popover'
import { Select, SelectTrigger, SelectValue } from './ui/select'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from './ui/command'
import { cn } from '../utils/cn'
import { Checkbox } from './ui/checkbox'
const schema = z
.object({
@@ -38,14 +52,70 @@ const schema = z
.trim()
.min(1)
.regex(/^[A-Za-záéíóúÁÉÍÓÚñÑçÇ]+$/, 'Use alphabetic characters only.'),
company: z.string(),
email: z.string().email(),
password: z.string().min(8),
confirmPassword: z.string().min(8),
_addMailingAddress: z.boolean(),
address: z
.object({
addressLine1: z.string(),
addressLine2: z.string(),
city: z.string(),
state: z.string(),
country: z.string(),
zip: z.string(),
_addressStateOptionsLength: z.number(),
})
.superRefine((data, ctx) => {
if (!data.state && data._addressStateOptionsLength) {
ctx.addIssue({
path: ['shippingState'],
code: 'custom',
message: 'State is required.',
})
}
}),
})
.refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match.',
path: ['confirmPassword'],
})
.superRefine((data, ctx) => {
if (data._addMailingAddress) {
if (!data.address.addressLine1) {
ctx.addIssue({
path: ['shipping.addressLine1'],
code: 'custom',
message: 'Address line 1 is required.',
})
}
if (!data.address.country) {
ctx.addIssue({
path: ['shipping.country'],
code: 'custom',
message: 'Country is required.',
})
}
if (!data.address.city) {
ctx.addIssue({
path: ['shipping.city'],
code: 'custom',
message: 'City is required.',
})
}
if (!data.address.zip) {
ctx.addIssue({
path: ['shipping.zip'],
code: 'custom',
message: 'Postal code is required.',
})
}
}
})
type RegisterFormInputs = z.infer<typeof schema>
@@ -53,10 +123,77 @@ type Props = { close: () => void; openLoginModal: () => void }
function RegisterFormModal({ close, openLoginModal }: Props) {
const { toast } = useToast()
const form = useForm<RegisterFormInputs>({ resolver: zodResolver(schema) })
const fundSlug = useFundSlug()
const getCountriesQuery = trpc.perk.getCountries.useQuery()
const registerMutation = trpc.auth.register.useMutation()
const form = useForm<RegisterFormInputs>({
resolver: zodResolver(schema),
mode: 'all',
defaultValues: {
firstName: '',
lastName: '',
company: '',
email: '',
password: '',
confirmPassword: '',
_addMailingAddress: false,
address: {
_addressStateOptionsLength: 0,
addressLine1: '',
addressLine2: '',
city: '',
country: '',
state: '',
zip: '',
},
},
})
const addressCountryOptions = (getCountriesQuery.data || []).map((country) => ({
label: country.name,
value: country.code,
}))
const addressCountry = form.watch('address.country')
const addressState = form.watch('address.state')
const addMailingAddress = form.watch('_addMailingAddress')
const addressStateOptions = useMemo(() => {
const selectedCountry = (getCountriesQuery.data || []).find(
(country) => country.code === addressCountry
)
const stateOptions =
selectedCountry?.states?.map((state) => ({
label: state.name,
value: state.code,
})) || []
return stateOptions
}, [addressCountry])
useEffect(() => {
form.setValue('address.state', '')
}, [addressCountry])
useEffect(() => {
form.setValue('address._addressStateOptionsLength', addressStateOptions.length)
}, [addressStateOptions])
useEffect(() => {
form.setValue('address._addressStateOptionsLength', 0)
form.setValue('address.addressLine1', '')
form.setValue('address.addressLine2', '')
form.setValue('address.city', '')
form.setValue('address.country', '')
form.setValue('address.state', '')
form.setValue('address.zip', '')
}, [addMailingAddress])
const [countrySelectOpen, setCountrySelectOpen] = useState(false)
const [stateSelectOpen, setStateSelectOpen] = useState(false)
async function onSubmit(data: RegisterFormInputs) {
if (!fundSlug) return
@@ -97,7 +234,7 @@ function RegisterFormModal({ close, openLoginModal }: Props) {
name="firstName"
render={({ field }) => (
<FormItem className="grow">
<FormLabel>First name</FormLabel>
<FormLabel>First name *</FormLabel>
<FormControl>
<Input placeholder="John" {...field} />
</FormControl>
@@ -111,7 +248,7 @@ function RegisterFormModal({ close, openLoginModal }: Props) {
name="lastName"
render={({ field }) => (
<FormItem className="grow">
<FormLabel>Last name</FormLabel>
<FormLabel>Last name *</FormLabel>
<FormControl>
<Input placeholder="Doe" {...field} />
</FormControl>
@@ -121,12 +258,26 @@ function RegisterFormModal({ close, openLoginModal }: Props) {
/>
</div>
<FormField
control={form.control}
name="company"
render={({ field }) => (
<FormItem>
<FormLabel>Company (optional)</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormLabel>Email *</FormLabel>
<FormControl>
<Input placeholder="johndoe@example.com" {...field} />
</FormControl>
@@ -140,7 +291,7 @@ function RegisterFormModal({ close, openLoginModal }: Props) {
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormLabel>Password *</FormLabel>
<FormControl>
<Input {...field} type="password" />
</FormControl>
@@ -154,7 +305,7 @@ function RegisterFormModal({ close, openLoginModal }: Props) {
name="confirmPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Confirm password</FormLabel>
<FormLabel>Confirm password *</FormLabel>
<FormControl>
<Input {...field} type="password" />
</FormControl>
@@ -163,6 +314,217 @@ function RegisterFormModal({ close, openLoginModal }: Props) {
)}
/>
<FormField
control={form.control}
name="_addMailingAddress"
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 flex flex-col items-start leading-none">
<FormLabel>Add mailing address</FormLabel>
<FormDescription>
You can also manage your mailing address in the account settings.
</FormDescription>
</div>
</FormItem>
)}
/>
{addMailingAddress && (
<>
<FormField
control={form.control}
name="address.addressLine1"
render={({ field }) => (
<FormItem>
<FormLabel>Address line 1 *</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="address.addressLine2"
render={({ field }) => (
<FormItem>
<FormLabel>Address line 2</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="address.country"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>Country *</FormLabel>
<Popover
modal
open={countrySelectOpen}
onOpenChange={(open) => setCountrySelectOpen(open)}
>
<PopoverTrigger>
<div>
<FormControl>
<Select
open={countrySelectOpen}
onValueChange={() => setCountrySelectOpen(false)}
>
<SelectTrigger>
<SelectValue
placeholder={
(getCountriesQuery.data || []).find(
(country) => country.code === addressCountry
)?.name || ''
}
/>
</SelectTrigger>
</Select>
</FormControl>
</div>
</PopoverTrigger>
<PopoverContent className="p-0">
<Command>
<CommandInput placeholder="Search country..." />
<CommandList>
<CommandEmpty>No country found.</CommandEmpty>
<CommandGroup>
{addressCountryOptions.map((country) => (
<CommandItem
value={country.label}
key={country.value}
onSelect={() => (
form.setValue('address.country', country.value, {
shouldValidate: true,
}),
setCountrySelectOpen(false)
)}
>
{country.label}
<Check
className={cn(
'ml-auto',
country.value === field.value ? 'opacity-100' : 'opacity-0'
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
{!!addressStateOptions.length && (
<FormField
control={form.control}
name="address.state"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>State *</FormLabel>
<Popover
modal
open={stateSelectOpen}
onOpenChange={(open) => setStateSelectOpen(open)}
>
<PopoverTrigger>
<div>
<FormControl>
<Select>
<SelectTrigger>
<SelectValue
placeholder={
addressStateOptions.find(
(state) => state.value === addressState
)?.label
}
/>
</SelectTrigger>
</Select>
</FormControl>
</div>
</PopoverTrigger>
<PopoverContent className="p-0">
<Command>
<CommandInput placeholder="Search state..." />
<CommandList>
<CommandEmpty>No state found.</CommandEmpty>
<CommandGroup>
{addressStateOptions.map((state) => (
<CommandItem
value={state.label}
key={state.value}
onSelect={() => (
form.setValue('address.state', state.value, {
shouldValidate: true,
}),
setStateSelectOpen(false)
)}
>
{state.label}
<Check
className={cn(
'ml-auto',
state.value === field.value ? 'opacity-100' : 'opacity-0'
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="address.city"
render={({ field }) => (
<FormItem>
<FormLabel>City *</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="address.zip"
render={({ field }) => (
<FormItem>
<FormLabel>Postal code *</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
)}
<div className="flex flex-row space-x-2">
<Button
type="button"

View File

@@ -0,0 +1,25 @@
import * as React from 'react'
import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
import { CheckIcon } from '@radix-ui/react-icons'
import { cn } from '../../utils/cn'
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
'peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
className
)}
{...props}
>
<CheckboxPrimitive.Indicator className={cn('flex items-center justify-center text-current')}>
<CheckIcon className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }

70
package-lock.json generated
View File

@@ -16,6 +16,7 @@
"@keycloak/keycloak-admin-client": "^24.0.5",
"@prisma/client": "^5.15.1",
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-checkbox": "^1.1.2",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-icons": "^1.3.0",
@@ -3207,6 +3208,75 @@
}
}
},
"node_modules/@radix-ui/react-checkbox": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.1.2.tgz",
"integrity": "sha512-/i0fl686zaJbDQLNKrkCbMyDm6FQMt4jg323k7HuqitoANm9sE23Ql8yOK3Wusk34HSLKDChhMux05FnP6KUkw==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.0",
"@radix-ui/react-compose-refs": "1.1.0",
"@radix-ui/react-context": "1.1.1",
"@radix-ui/react-presence": "1.1.1",
"@radix-ui/react-primitive": "2.0.0",
"@radix-ui/react-use-controllable-state": "1.1.0",
"@radix-ui/react-use-previous": "1.1.0",
"@radix-ui/react-use-size": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-context": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz",
"integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-presence": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.1.tgz",
"integrity": "sha512-IeFXVi4YS1K0wVZzXNrbaaUvIJ3qdY+/Ih4eHFhWA9SwGR9UDX7Ck8abvL57C4cv3wwMvUE0OG69Qc3NCcTe/A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.0",
"@radix-ui/react-use-layout-effect": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-collection": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.0.tgz",

View File

@@ -18,6 +18,7 @@
"@keycloak/keycloak-admin-client": "^24.0.5",
"@prisma/client": "^5.15.1",
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-checkbox": "^1.1.2",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-icons": "^1.3.0",

View File

@@ -1,3 +1,5 @@
import { useEffect, useMemo, useState } from 'react'
import { Check } from 'lucide-react'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import Head from 'next/head'
@@ -19,6 +21,21 @@ import { trpc } from '../../../utils/trpc'
import { useFundSlug } from '../../../utils/use-fund-slug'
import { signOut, useSession } from 'next-auth/react'
import { useRouter } from 'next/router'
import { Popover, PopoverContent, PopoverTrigger } from '../../../components/ui/popover'
import { Select, SelectTrigger, SelectValue } from '../../../components/ui/select'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '../../../components/ui/command'
import { cn } from '../../../utils/cn'
const changeProfileFormSchema = z.object({ company: z.string() })
const changeEmailFormSchema = z.object({ newEmail: z.string().email() })
const changePasswordFormSchema = z
.object({
@@ -31,17 +48,54 @@ const changePasswordFormSchema = z
path: ['confirmNewPassword'],
})
const changeEmailFormSchema = z.object({ newEmail: z.string().email() })
const changeMailingAddressFormSchema = z
.object({
addressLine1: z.string().min(1),
addressLine2: z.string(),
city: z.string().min(1),
state: z.string(),
country: z.string().min(1),
zip: z.string().min(1),
_addressStateOptionsLength: z.number(),
})
.superRefine((data, ctx) => {
if (!data.state && data._addressStateOptionsLength) {
ctx.addIssue({
path: ['state'],
code: 'custom',
message: 'State is required.',
})
return
}
})
type ChangePasswordFormInputs = z.infer<typeof changePasswordFormSchema>
type ChangeProfileFormInputs = z.infer<typeof changeProfileFormSchema>
type ChangeEmailFormInputs = z.infer<typeof changeEmailFormSchema>
type ChangePasswordFormInputs = z.infer<typeof changePasswordFormSchema>
type ChangeMailingAddressFormInputs = z.infer<typeof changeMailingAddressFormSchema>
function Settings() {
const router = useRouter()
const fundSlug = useFundSlug()
const session = useSession()
const changePasswordMutation = trpc.account.changePassword.useMutation()
const getUserAttributesQuery = trpc.account.getUserAttributes.useQuery()
const getCountriesQuery = trpc.perk.getCountries.useQuery()
const changeProfileMutation = trpc.account.changeProfile.useMutation()
const requestEmailChangeMutation = trpc.account.requestEmailChange.useMutation()
const changePasswordMutation = trpc.account.changePassword.useMutation()
const changeMailingAddressMutation = trpc.account.changeMailingAddress.useMutation()
const changeProfileForm = useForm<ChangeProfileFormInputs>({
resolver: zodResolver(changeProfileFormSchema),
defaultValues: { company: '' },
mode: 'all',
})
const changeEmailForm = useForm<ChangeEmailFormInputs>({
resolver: zodResolver(changeEmailFormSchema),
defaultValues: { newEmail: '' },
mode: 'all',
})
const changePasswordForm = useForm<ChangePasswordFormInputs>({
resolver: zodResolver(changePasswordFormSchema),
@@ -53,35 +107,72 @@ function Settings() {
mode: 'all',
})
const changeEmailForm = useForm<ChangeEmailFormInputs>({
resolver: zodResolver(changeEmailFormSchema),
defaultValues: { newEmail: '' },
const changeMailingAddressForm = useForm<ChangeMailingAddressFormInputs>({
resolver: zodResolver(changeMailingAddressFormSchema),
defaultValues: {
addressLine1: '',
addressLine2: '',
zip: '',
city: '',
state: '',
country: '',
},
mode: 'all',
})
async function onChangePasswordSubmit(data: ChangePasswordFormInputs) {
const addressCountryOptions = (getCountriesQuery.data || []).map((country) => ({
label: country.name,
value: country.code,
}))
const addressCountry = changeMailingAddressForm.watch('country')
const addressState = changeMailingAddressForm.watch('state')
const addressStateOptions = useMemo(() => {
const selectedCountry = (getCountriesQuery.data || []).find(
(country) => country.code === addressCountry
)
const stateOptions =
selectedCountry?.states?.map((state) => ({
label: state.name,
value: state.code,
})) || []
return stateOptions
}, [addressCountry])
useEffect(() => {
changeMailingAddressForm.setValue('state', '')
}, [addressCountry])
useEffect(() => {
changeMailingAddressForm.setValue('_addressStateOptionsLength', addressStateOptions.length)
}, [addressStateOptions])
useEffect(() => {
if (!getUserAttributesQuery.data) return
changeProfileForm.setValue('company', getUserAttributesQuery.data.company)
changeMailingAddressForm.setValue('addressLine1', getUserAttributesQuery.data.addressLine1)
changeMailingAddressForm.setValue('addressLine2', getUserAttributesQuery.data.addressLine2)
changeMailingAddressForm.setValue('country', getUserAttributesQuery.data.addressCountry)
changeMailingAddressForm.setValue('city', getUserAttributesQuery.data.addressCity)
changeMailingAddressForm.setValue('zip', getUserAttributesQuery.data.addressZip)
setTimeout(
() => changeMailingAddressForm.setValue('state', getUserAttributesQuery.data.addressState),
20
)
}, [getUserAttributesQuery.data])
const [countrySelectOpen, setCountrySelectOpen] = useState(false)
const [stateSelectOpen, setStateSelectOpen] = useState(false)
async function onChangeProfileSubmit(data: ChangeProfileFormInputs) {
try {
await changePasswordMutation.mutateAsync({
currentPassword: data.currentPassword,
newPassword: data.newPassword,
})
changePasswordForm.reset()
toast({ title: 'Password successfully changed! Please log in again.' })
await signOut({ redirect: false })
router.push(`/${fundSlug}/?loginEmail=${session.data?.user.email}`)
await changeProfileMutation.mutateAsync(data)
toast({ title: 'Your profile has successfully been changed!' })
} catch (error) {
const errorMessage = (error as any).message
if (errorMessage === 'INVALID_PASSWORD') {
return changePasswordForm.setError(
'currentPassword',
{ message: 'Invalid password.' },
{ shouldFocus: true }
)
}
return toast({
title: 'Sorry, something went wrong.',
variant: 'destructive',
@@ -114,6 +205,48 @@ function Settings() {
}
}
async function onChangePasswordSubmit(data: ChangePasswordFormInputs) {
try {
await changePasswordMutation.mutateAsync({
currentPassword: data.currentPassword,
newPassword: data.newPassword,
})
changePasswordForm.reset()
toast({ title: 'Your password has successfully been changed! Please log in again.' })
await signOut({ redirect: false })
router.push(`/${fundSlug}/?loginEmail=${session.data?.user.email}`)
} catch (error) {
const errorMessage = (error as any).message
if (errorMessage === 'INVALID_PASSWORD') {
return changePasswordForm.setError(
'currentPassword',
{ message: 'Invalid password.' },
{ shouldFocus: true }
)
}
return toast({
title: 'Sorry, something went wrong.',
variant: 'destructive',
})
}
}
async function onChangeMailingAddressSubmit(data: ChangeMailingAddressFormInputs) {
try {
await changeMailingAddressMutation.mutateAsync(data)
toast({ title: 'Your mailing address has successfully been changed!' })
} catch (error) {
return toast({
title: 'Sorry, something went wrong.',
variant: 'destructive',
})
}
}
return (
<>
<Head>
@@ -121,6 +254,72 @@ function Settings() {
</Head>
<div className="w-full max-w-lg mx-auto flex flex-col space-y-12">
<div className="w-full flex flex-col space-y-6">
<h1 className="font-bold">Change Profile</h1>
<Form {...changeEmailForm}>
<form
onSubmit={changeProfileForm.handleSubmit(onChangeProfileSubmit)}
className="flex flex-col space-y-4"
>
<FormField
control={changeProfileForm.control}
name="company"
render={({ field }) => (
<FormItem>
<FormLabel>Company</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
disabled={
changeProfileForm.formState.isSubmitting || !changeProfileForm.formState.isValid
}
>
{changeProfileForm.formState.isSubmitting && <Spinner />} Change Profile
</Button>
</form>
</Form>
</div>
<div className="w-full flex flex-col space-y-6">
<h1 className="font-bold">Change Email</h1>
<Form {...changeEmailForm}>
<form
onSubmit={changeEmailForm.handleSubmit(onChangeEmailSubmit)}
className="flex flex-col space-y-4"
>
<FormField
control={changeEmailForm.control}
name="newEmail"
render={({ field }) => (
<FormItem>
<FormLabel>Email *</FormLabel>
<FormControl>
<Input placeholder={session.data?.user.email} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
disabled={
changeEmailForm.formState.isSubmitting || !changeEmailForm.formState.isValid
}
>
{changeEmailForm.formState.isSubmitting && <Spinner />} Change Email
</Button>
</form>
</Form>
</div>
<div className="w-full flex flex-col space-y-6">
<h1 className="font-bold">Change Password</h1>
@@ -134,7 +333,7 @@ function Settings() {
name="currentPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Current password</FormLabel>
<FormLabel>Current password *</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
@@ -148,7 +347,7 @@ function Settings() {
name="newPassword"
render={({ field }) => (
<FormItem>
<FormLabel>New password</FormLabel>
<FormLabel>New password *</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
@@ -162,7 +361,7 @@ function Settings() {
name="confirmNewPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Confirm new password</FormLabel>
<FormLabel>Confirm new password *</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
@@ -183,21 +382,197 @@ function Settings() {
</div>
<div className="w-full flex flex-col space-y-6">
<h1 className="font-bold">Change Email</h1>
<h1 className="font-bold">Change Mailing Address</h1>
<Form {...changeEmailForm}>
<form
onSubmit={changeEmailForm.handleSubmit(onChangeEmailSubmit)}
onSubmit={changeMailingAddressForm.handleSubmit(onChangeMailingAddressSubmit)}
className="flex flex-col space-y-4"
>
<FormField
control={changeEmailForm.control}
name="newEmail"
control={changeMailingAddressForm.control}
name="addressLine1"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormLabel>Address line 1 *</FormLabel>
<FormControl>
<Input placeholder={session.data?.user.email} {...field} />
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={changeMailingAddressForm.control}
name="addressLine2"
render={({ field }) => (
<FormItem>
<FormLabel>Address line 2</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={changeMailingAddressForm.control}
name="country"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>Country *</FormLabel>
<Popover
modal
open={countrySelectOpen}
onOpenChange={(open) => setCountrySelectOpen(open)}
>
<PopoverTrigger asChild>
<div>
<FormControl>
<Select
open={countrySelectOpen}
onValueChange={() => setCountrySelectOpen(false)}
>
<SelectTrigger>
<SelectValue
placeholder={
(getCountriesQuery.data || []).find(
(country) => country.code === addressCountry
)?.name || ''
}
/>
</SelectTrigger>
</Select>
</FormControl>
</div>
</PopoverTrigger>
<PopoverContent className="p-0">
<Command>
<CommandInput placeholder="Search country..." />
<CommandList>
<CommandEmpty>No country found.</CommandEmpty>
<CommandGroup>
{addressCountryOptions.map((country) => (
<CommandItem
value={country.label}
key={country.value}
onSelect={() => (
changeMailingAddressForm.setValue('country', country.value, {
shouldValidate: true,
}),
setCountrySelectOpen(false)
)}
>
{country.label}
<Check
className={cn(
'ml-auto',
country.value === field.value ? 'opacity-100' : 'opacity-0'
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
{!!addressStateOptions.length && (
<FormField
control={changeMailingAddressForm.control}
name="state"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>State *</FormLabel>
<Popover
modal
open={stateSelectOpen}
onOpenChange={(open) => setStateSelectOpen(open)}
>
<PopoverTrigger asChild>
<div>
<FormControl>
<Select>
<SelectTrigger>
<SelectValue
placeholder={
addressStateOptions.find(
(state) => state.value === addressState
)?.label
}
/>
</SelectTrigger>
</Select>
</FormControl>
</div>
</PopoverTrigger>
<PopoverContent className="p-0">
<Command>
<CommandInput placeholder="Search state..." />
<CommandList>
<CommandEmpty>No state found.</CommandEmpty>
<CommandGroup>
{addressStateOptions.map((state) => (
<CommandItem
value={state.label}
key={state.value}
onSelect={() => {
console.log('asdasd')
changeMailingAddressForm.setValue('state', state.value, {
shouldValidate: true,
})
setStateSelectOpen(false)
}}
>
{state.label}
<Check
className={cn(
'ml-auto',
state.value === field.value ? 'opacity-100' : 'opacity-0'
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={changeMailingAddressForm.control}
name="city"
render={({ field }) => (
<FormItem>
<FormLabel>City *</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={changeMailingAddressForm.control}
name="zip"
render={({ field }) => (
<FormItem>
<FormLabel>Postal code *</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -206,10 +581,12 @@ function Settings() {
<Button
disabled={
changeEmailForm.formState.isSubmitting || !changeEmailForm.formState.isValid
changeMailingAddressForm.formState.isSubmitting ||
!changeMailingAddressForm.formState.isValid
}
>
{changeEmailForm.formState.isSubmitting && <Spinner />} Change Email
{changeMailingAddressForm.formState.isSubmitting && <Spinner />} Change Mailing
Address
</Button>
</form>
</Form>

View File

@@ -72,10 +72,18 @@
"description": "${role_default-roles}",
"composite": true,
"composites": {
"realm": ["offline_access", "uma_authorization"],
"realm": [
"offline_access",
"uma_authorization"
],
"client": {
"realm-management": ["manage-users"],
"account": ["view-profile", "manage-account"]
"realm-management": [
"manage-users"
],
"account": [
"view-profile",
"manage-account"
]
}
},
"clientRole": false,
@@ -129,7 +137,10 @@
"composite": true,
"composites": {
"client": {
"realm-management": ["query-users", "query-groups"]
"realm-management": [
"query-users",
"query-groups"
]
}
},
"clientRole": true,
@@ -275,7 +286,9 @@
"composite": true,
"composites": {
"client": {
"realm-management": ["query-clients"]
"realm-management": [
"query-clients"
]
}
},
"clientRole": true,
@@ -323,7 +336,9 @@
"composite": true,
"composites": {
"client": {
"account": ["view-consent"]
"account": [
"view-consent"
]
}
},
"clientRole": true,
@@ -382,7 +397,9 @@
"composite": true,
"composites": {
"client": {
"account": ["manage-account-links"]
"account": [
"manage-account-links"
]
}
},
"clientRole": true,
@@ -401,7 +418,9 @@
"clientRole": false,
"containerId": "69206f5b-3557-4d79-aa26-e42faeaa6004"
},
"requiredCredentials": ["password"],
"requiredCredentials": [
"password"
],
"otpPolicyType": "totp",
"otpPolicyAlgorithm": "HmacSHA1",
"otpPolicyInitialCounter": 0,
@@ -416,7 +435,9 @@
],
"localizationTexts": {},
"webAuthnPolicyRpEntityName": "keycloak",
"webAuthnPolicySignatureAlgorithms": ["ES256"],
"webAuthnPolicySignatureAlgorithms": [
"ES256"
],
"webAuthnPolicyRpId": "",
"webAuthnPolicyAttestationConveyancePreference": "not specified",
"webAuthnPolicyAuthenticatorAttachment": "not specified",
@@ -427,7 +448,9 @@
"webAuthnPolicyAcceptableAaguids": [],
"webAuthnPolicyExtraOrigins": [],
"webAuthnPolicyPasswordlessRpEntityName": "keycloak",
"webAuthnPolicyPasswordlessSignatureAlgorithms": ["ES256"],
"webAuthnPolicyPasswordlessSignatureAlgorithms": [
"ES256"
],
"webAuthnPolicyPasswordlessRpId": "",
"webAuthnPolicyPasswordlessAttestationConveyancePreference": "not specified",
"webAuthnPolicyPasswordlessAuthenticatorAttachment": "not specified",
@@ -448,7 +471,9 @@
"serviceAccountClientId": "app",
"disableableCredentialTypes": [],
"requiredActions": [],
"realmRoles": ["default-roles-magicgrants"],
"realmRoles": [
"default-roles-magicgrants"
],
"notBefore": 0,
"groups": []
}
@@ -456,14 +481,19 @@
"scopeMappings": [
{
"clientScope": "offline_access",
"roles": ["offline_access"]
"roles": [
"offline_access"
]
}
],
"clientScopeMappings": {
"account": [
{
"client": "account-console",
"roles": ["manage-account", "view-groups"]
"roles": [
"manage-account",
"view-groups"
]
}
]
},
@@ -478,7 +508,9 @@
"enabled": true,
"alwaysDisplayInConsole": false,
"clientAuthenticatorType": "client-secret",
"redirectUris": ["/realms/magic/account/*"],
"redirectUris": [
"/realms/magic/account/*"
],
"webOrigins": [],
"notBefore": 0,
"bearerOnly": false,
@@ -496,7 +528,9 @@
"authenticationFlowBindingOverrides": {},
"fullScopeAllowed": false,
"nodeReRegistrationTimeout": 0,
"defaultClientScopes": ["basic"],
"defaultClientScopes": [
"basic"
],
"optionalClientScopes": []
},
{
@@ -509,7 +543,9 @@
"enabled": true,
"alwaysDisplayInConsole": false,
"clientAuthenticatorType": "client-secret",
"redirectUris": ["/realms/magic/account/*"],
"redirectUris": [
"/realms/magic/account/*"
],
"webOrigins": [],
"notBefore": 0,
"bearerOnly": false,
@@ -538,7 +574,9 @@
"config": {}
}
],
"defaultClientScopes": ["basic"],
"defaultClientScopes": [
"basic"
],
"optionalClientScopes": []
},
{
@@ -567,7 +605,9 @@
"authenticationFlowBindingOverrides": {},
"fullScopeAllowed": false,
"nodeReRegistrationTimeout": 0,
"defaultClientScopes": ["basic"],
"defaultClientScopes": [
"basic"
],
"optionalClientScopes": []
},
{
@@ -583,8 +623,12 @@
"alwaysDisplayInConsole": false,
"clientAuthenticatorType": "client-secret",
"secret": "**********",
"redirectUris": ["/*"],
"webOrigins": ["/*"],
"redirectUris": [
"/*"
],
"webOrigins": [
"/*"
],
"notBefore": 0,
"bearerOnly": false,
"consentRequired": false,
@@ -597,7 +641,7 @@
"protocol": "openid-connect",
"attributes": {
"oidc.ciba.grant.enabled": "false",
"client.secret.creation.time": "1724090232",
"client.secret.creation.time": "1729548068",
"backchannel.logout.session.required": "true",
"post.logout.redirect.uris": "+",
"oauth2.device.authorization.grant.enabled": "false",
@@ -656,8 +700,20 @@
}
}
],
"defaultClientScopes": ["web-origins", "acr", "roles", "profile", "basic", "email"],
"optionalClientScopes": ["address", "phone", "offline_access", "microprofile-jwt"]
"defaultClientScopes": [
"web-origins",
"acr",
"roles",
"profile",
"basic",
"email"
],
"optionalClientScopes": [
"address",
"phone",
"offline_access",
"microprofile-jwt"
]
},
{
"id": "b44227a1-ce19-41b0-8b61-21ce79039e35",
@@ -727,8 +783,12 @@
"enabled": true,
"alwaysDisplayInConsole": false,
"clientAuthenticatorType": "client-secret",
"redirectUris": ["/admin/magic/console/*"],
"webOrigins": ["+"],
"redirectUris": [
"/admin/magic/console/*"
],
"webOrigins": [
"+"
],
"notBefore": 0,
"bearerOnly": false,
"consentRequired": false,
@@ -764,7 +824,9 @@
}
}
],
"defaultClientScopes": ["basic"],
"defaultClientScopes": [
"basic"
],
"optionalClientScopes": []
}
],
@@ -1356,7 +1418,12 @@
"acr",
"basic"
],
"defaultOptionalClientScopes": ["offline_access", "address", "phone", "microprofile-jwt"],
"defaultOptionalClientScopes": [
"offline_access",
"address",
"phone",
"microprofile-jwt"
],
"browserSecurityHeaders": {
"contentSecurityPolicyReportOnly": "",
"xContentTypeOptions": "nosniff",
@@ -1369,7 +1436,9 @@
},
"smtpServer": {},
"eventsEnabled": false,
"eventsListeners": ["jboss-logging"],
"eventsListeners": [
"jboss-logging"
],
"enabledEventTypes": [],
"adminEventsEnabled": false,
"adminEventsDetailsEnabled": false,
@@ -1384,7 +1453,9 @@
"subType": "anonymous",
"subComponents": {},
"config": {
"max-clients": ["200"]
"max-clients": [
"200"
]
}
},
{
@@ -1404,13 +1475,13 @@
"config": {
"allowed-protocol-mapper-types": [
"oidc-usermodel-attribute-mapper",
"saml-user-property-mapper",
"oidc-sha256-pairwise-sub-mapper",
"oidc-full-name-mapper",
"oidc-address-mapper",
"saml-role-list-mapper",
"oidc-usermodel-property-mapper",
"saml-user-attribute-mapper"
"saml-user-property-mapper",
"saml-role-list-mapper",
"oidc-address-mapper",
"saml-user-attribute-mapper",
"oidc-sha256-pairwise-sub-mapper",
"oidc-full-name-mapper"
]
}
},
@@ -1429,7 +1500,9 @@
"subType": "anonymous",
"subComponents": {},
"config": {
"allow-default-scopes": ["true"]
"allow-default-scopes": [
"true"
]
}
},
{
@@ -1439,8 +1512,12 @@
"subType": "anonymous",
"subComponents": {},
"config": {
"host-sending-registration-request-must-match": ["true"],
"client-uris-must-match": ["true"]
"host-sending-registration-request-must-match": [
"true"
],
"client-uris-must-match": [
"true"
]
}
},
{
@@ -1451,13 +1528,13 @@
"subComponents": {},
"config": {
"allowed-protocol-mapper-types": [
"oidc-sha256-pairwise-sub-mapper",
"saml-user-attribute-mapper",
"oidc-usermodel-property-mapper",
"oidc-address-mapper",
"oidc-full-name-mapper",
"saml-role-list-mapper",
"saml-user-attribute-mapper",
"oidc-usermodel-attribute-mapper",
"oidc-sha256-pairwise-sub-mapper",
"saml-role-list-mapper",
"saml-user-property-mapper"
]
}
@@ -1469,7 +1546,9 @@
"subType": "authenticated",
"subComponents": {},
"config": {
"allow-default-scopes": ["true"]
"allow-default-scopes": [
"true"
]
}
}
],
@@ -1480,7 +1559,7 @@
"subComponents": {},
"config": {
"kc.user.profile.config": [
"{\"attributes\":[{\"name\":\"username\",\"displayName\":\"${username}\",\"validations\":{\"length\":{\"min\":3,\"max\":255},\"username-prohibited-characters\":{},\"up-username-not-idn-homograph\":{}},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"email\",\"displayName\":\"${email}\",\"validations\":{\"email\":{},\"length\":{\"max\":255}},\"required\":{\"roles\":[\"user\"]},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"name\",\"displayName\":\"\",\"validations\":{},\"annotations\":{},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\"]},\"multivalued\":false},{\"name\":\"emailVerifyTokenVersion\",\"displayName\":\"\",\"validations\":{},\"annotations\":{},\"permissions\":{\"view\":[\"admin\"],\"edit\":[\"admin\"]},\"multivalued\":false},{\"name\":\"passwordResetTokenVersion\",\"displayName\":\"\",\"validations\":{},\"annotations\":{},\"permissions\":{\"view\":[\"admin\"],\"edit\":[\"admin\"]},\"multivalued\":false},{\"name\":\"stripeMoneroCustomerId\",\"displayName\":\"\",\"validations\":{},\"annotations\":{},\"permissions\":{\"view\":[\"admin\"],\"edit\":[\"admin\"]},\"multivalued\":false},{\"name\":\"stripeFiroCustomerId\",\"displayName\":\"\",\"validations\":{},\"annotations\":{},\"permissions\":{\"view\":[\"admin\"],\"edit\":[\"admin\"]},\"multivalued\":false},{\"name\":\"stripePgCustomerId\",\"displayName\":\"\",\"validations\":{},\"annotations\":{},\"permissions\":{\"view\":[],\"edit\":[\"admin\"]},\"multivalued\":false},{\"name\":\"stripeGeneralCustomerId\",\"displayName\":\"\",\"validations\":{},\"annotations\":{},\"permissions\":{\"view\":[\"admin\"],\"edit\":[\"admin\"]},\"multivalued\":false}],\"groups\":[{\"name\":\"user-metadata\",\"displayHeader\":\"User metadata\",\"displayDescription\":\"Attributes, which refer to user metadata\"}]}"
"{\"attributes\":[{\"name\":\"username\",\"displayName\":\"${username}\",\"validations\":{\"length\":{\"min\":3,\"max\":255},\"username-prohibited-characters\":{},\"up-username-not-idn-homograph\":{}},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"email\",\"displayName\":\"${email}\",\"validations\":{\"email\":{},\"length\":{\"max\":255}},\"required\":{\"roles\":[\"user\"]},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"name\",\"displayName\":\"\",\"validations\":{},\"annotations\":{},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\"]},\"multivalued\":false},{\"name\":\"addressLine1\",\"displayName\":\"Address line 1\",\"validations\":{},\"annotations\":{},\"permissions\":{\"view\":[\"admin\"],\"edit\":[\"admin\"]},\"group\":\"mailingAddress\",\"multivalued\":false},{\"name\":\"addressLine2\",\"displayName\":\"Address line 2\",\"validations\":{},\"annotations\":{},\"permissions\":{\"view\":[\"admin\"],\"edit\":[\"admin\"]},\"group\":\"mailingAddress\",\"multivalued\":false},{\"name\":\"addressZip\",\"displayName\":\"Address zip\",\"validations\":{},\"annotations\":{},\"permissions\":{\"view\":[\"admin\"],\"edit\":[\"admin\"]},\"group\":\"mailingAddress\",\"multivalued\":false},{\"name\":\"addressCity\",\"displayName\":\"Address city\",\"validations\":{},\"annotations\":{},\"permissions\":{\"view\":[\"admin\"],\"edit\":[\"admin\"]},\"group\":\"mailingAddress\",\"multivalued\":false},{\"name\":\"addressState\",\"displayName\":\"Address state\",\"validations\":{},\"annotations\":{},\"permissions\":{\"view\":[\"admin\"],\"edit\":[\"admin\"]},\"group\":\"mailingAddress\",\"multivalued\":false},{\"name\":\"addressCountry\",\"displayName\":\"Address country\",\"validations\":{},\"annotations\":{},\"permissions\":{\"view\":[\"admin\"],\"edit\":[\"admin\"]},\"group\":\"mailingAddress\",\"multivalued\":false},{\"name\":\"emailVerifyTokenVersion\",\"displayName\":\"\",\"validations\":{},\"annotations\":{},\"permissions\":{\"view\":[\"admin\"],\"edit\":[\"admin\"]},\"multivalued\":false},{\"name\":\"passwordResetTokenVersion\",\"displayName\":\"\",\"validations\":{},\"annotations\":{},\"permissions\":{\"view\":[\"admin\"],\"edit\":[\"admin\"]},\"multivalued\":false},{\"name\":\"stripeMoneroCustomerId\",\"displayName\":\"\",\"validations\":{},\"annotations\":{},\"permissions\":{\"view\":[\"admin\"],\"edit\":[\"admin\"]},\"multivalued\":false},{\"name\":\"stripeFiroCustomerId\",\"displayName\":\"\",\"validations\":{},\"annotations\":{},\"permissions\":{\"view\":[\"admin\"],\"edit\":[\"admin\"]},\"multivalued\":false},{\"name\":\"stripePgCustomerId\",\"displayName\":\"\",\"validations\":{},\"annotations\":{},\"permissions\":{\"view\":[],\"edit\":[\"admin\"]},\"multivalued\":false},{\"name\":\"stripeGeneralCustomerId\",\"displayName\":\"\",\"validations\":{},\"annotations\":{},\"permissions\":{\"view\":[\"admin\"],\"edit\":[\"admin\"]},\"multivalued\":false},{\"name\":\"company\",\"displayName\":\"Company\",\"validations\":{},\"annotations\":{},\"permissions\":{\"view\":[\"admin\"],\"edit\":[\"admin\"]},\"multivalued\":false}],\"groups\":[{\"name\":\"user-metadata\",\"displayHeader\":\"User metadata\",\"displayDescription\":\"Attributes, which refer to user metadata\"},{\"name\":\"mailingAddress\",\"displayHeader\":\"Mailing address\",\"displayDescription\":\"\",\"annotations\":{}}]}"
]
}
}
@@ -1492,8 +1571,12 @@
"providerId": "rsa-enc-generated",
"subComponents": {},
"config": {
"priority": ["100"],
"algorithm": ["RSA-OAEP"]
"priority": [
"100"
],
"algorithm": [
"RSA-OAEP"
]
}
},
{
@@ -1502,7 +1585,9 @@
"providerId": "aes-generated",
"subComponents": {},
"config": {
"priority": ["100"]
"priority": [
"100"
]
}
},
{
@@ -1511,7 +1596,9 @@
"providerId": "rsa-generated",
"subComponents": {},
"config": {
"priority": ["100"]
"priority": [
"100"
]
}
},
{
@@ -1520,8 +1607,12 @@
"providerId": "hmac-generated",
"subComponents": {},
"config": {
"priority": ["100"],
"algorithm": ["HS512"]
"priority": [
"100"
],
"algorithm": [
"HS512"
]
}
}
]
@@ -2199,4 +2290,4 @@
"clientPolicies": {
"policies": []
}
}
}

View File

@@ -12,6 +12,36 @@ import { authenticateKeycloakClient } from '../utils/keycloak'
import { fundSlugs } from '../../utils/funds'
export const accountRouter = router({
changeProfile: protectedProcedure
.input(
z.object({
company: z.string(),
})
)
.mutation(async ({ input, ctx }) => {
await authenticateKeycloakClient()
const userId = ctx.session.user.sub
const user = await keycloak.users.findOne({ id: userId })
if (!user || !user.id)
throw new TRPCError({
code: 'NOT_FOUND',
message: 'USER_NOT_FOUND',
})
await keycloak.users.update(
{ id: userId },
{
...user,
attributes: {
...user.attributes,
company: input.company,
},
}
)
}),
changePassword: protectedProcedure
.input(z.object({ currentPassword: z.string().min(1), newPassword: z.string().min(1) }))
.mutation(async ({ input, ctx }) => {
@@ -119,4 +149,67 @@ export const accountRouter = router({
html: `<a href="${env.APP_URL}/${input.fundSlug}/verify-email/${token}" target="_blank">Verify email</a>`,
})
}),
changeMailingAddress: protectedProcedure
.input(
z.object({
addressLine1: z.string().min(1),
addressLine2: z.string(),
city: z.string().min(1),
state: z.string(),
country: z.string().min(1),
zip: z.string().min(1),
})
)
.mutation(async ({ input, ctx }) => {
await authenticateKeycloakClient()
const userId = ctx.session.user.sub
const user = await keycloak.users.findOne({ id: userId })
if (!user || !user.id)
throw new TRPCError({
code: 'NOT_FOUND',
message: 'USER_NOT_FOUND',
})
await keycloak.users.update(
{ id: userId },
{
...user,
attributes: {
...user.attributes,
addressLine1: input.addressLine1,
addressLine2: input.addressLine2,
addressZip: input.zip,
addressCity: input.city,
addressState: input.state,
addressCountry: input.country,
},
}
)
}),
getUserAttributes: protectedProcedure.query(async ({ ctx }) => {
await authenticateKeycloakClient()
const userId = ctx.session.user.sub
const user = await keycloak.users.findOne({ id: userId })
if (!user || !user.id)
throw new TRPCError({
code: 'NOT_FOUND',
message: 'USER_NOT_FOUND',
})
return {
company: (user.attributes?.company?.[0] as string) || '',
addressLine1: (user.attributes?.addressLine1?.[0] as string) || '',
addressLine2: (user.attributes?.addressLine2?.[0] as string) || '',
addressZip: (user.attributes?.addressZip?.[0] as string) || '',
addressCity: (user.attributes?.addressCity?.[0] as string) || '',
addressState: (user.attributes?.addressState?.[0] as string) || '',
addressCountry: (user.attributes?.addressCountry?.[0] as string) || '',
}
}),
})

View File

@@ -12,21 +12,83 @@ import { UserSettingsJwtPayload } from '../types'
export const authRouter = router({
register: publicProcedure
.input(
z.object({
firstName: z
.string()
.trim()
.min(1)
.regex(/^[A-Za-záéíóúÁÉÍÓÚñÑçÇ]+$/, 'Use alphabetic characters only.'),
lastName: z
.string()
.trim()
.min(1)
.regex(/^[A-Za-záéíóúÁÉÍÓÚñÑçÇ]+$/, 'Use alphabetic characters only.'),
email: z.string().email(),
password: z.string(),
fundSlug: z.enum(fundSlugs),
})
z
.object({
firstName: z
.string()
.trim()
.min(1)
.regex(/^[A-Za-záéíóúÁÉÍÓÚñÑçÇ]+$/, 'Use alphabetic characters only.'),
lastName: z
.string()
.trim()
.min(1)
.regex(/^[A-Za-záéíóúÁÉÍÓÚñÑçÇ]+$/, 'Use alphabetic characters only.'),
company: z.string(),
email: z.string().email(),
password: z.string().min(8),
confirmPassword: z.string().min(8),
fundSlug: z.enum(fundSlugs),
_addMailingAddress: z.boolean(),
address: z
.object({
addressLine1: z.string(),
addressLine2: z.string(),
city: z.string(),
state: z.string(),
country: z.string(),
zip: z.string(),
_addressStateOptionsLength: z.number(),
})
.superRefine((data, ctx) => {
if (!data.state && data._addressStateOptionsLength) {
ctx.addIssue({
path: ['shippingState'],
code: 'custom',
message: 'State is required.',
})
}
}),
})
.refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match.',
path: ['confirmPassword'],
})
.superRefine((data, ctx) => {
if (data._addMailingAddress) {
if (!data.address.addressLine1) {
ctx.addIssue({
path: ['shipping.addressLine1'],
code: 'custom',
message: 'Address line 1 is required.',
})
}
if (!data.address.country) {
ctx.addIssue({
path: ['shipping.country'],
code: 'custom',
message: 'Country is required.',
})
}
if (!data.address.city) {
ctx.addIssue({
path: ['shipping.city'],
code: 'custom',
message: 'City is required.',
})
}
if (!data.address.zip) {
ctx.addIssue({
path: ['shipping.zip'],
code: 'custom',
message: 'Postal code is required.',
})
}
}
})
)
.mutation(async ({ input }) => {
await authenticateKeycloakClient()
@@ -43,6 +105,13 @@ export const authRouter = router({
name: `${input.firstName} ${input.lastName}`,
passwordResetTokenVersion: 1,
emailVerifyTokenVersion: 1,
company: input.company,
addressLine1: input._addMailingAddress ? input.address.addressLine1 : '',
addressLine2: input._addMailingAddress ? input.address.addressLine2 : '',
addressCountry: input._addMailingAddress ? input.address.country : '',
addressState: input._addMailingAddress ? input.address.state : '',
addressCity: input._addMailingAddress ? input.address.city : '',
addressZip: input._addMailingAddress ? input.address.zip : '',
},
enabled: true,
})

View File

@@ -4,23 +4,14 @@ import { QueueEvents } from 'bullmq'
import { fundSlugs } from '../../utils/funds'
import { keycloak, printfulApi, prisma, strapiApi } from '../services'
import {
PrintfulCreateOrderReq,
PrintfulCreateOrderRes,
PrintfulEstimateOrderReq,
PrintfulEstimateOrderRes,
PrintfulGetCountriesRes,
PrintfulGetProductRes,
StrapiCreateOrderBody,
StrapiCreateOrderRes,
StrapiCreatePointBody,
StrapiGetPerkRes,
StrapiGetPerksPopulatedRes,
StrapiGetPointsPopulatedRes,
StrapiPerk,
} from '../types'
import { TRPCError } from '@trpc/server'
import { estimatePrintfulOrderCost, getUserPointBalance } from '../utils/perks'
import { AxiosResponse } from 'axios'
import { POINTS_REDEEM_PRICE_USD } from '../../config'
import { authenticateKeycloakClient } from '../utils/keycloak'
import { perkPurchaseQueue } from '../queues'
@@ -68,7 +59,7 @@ export const perkRouter = router({
return printfulProduct.sync_variants
}),
getCountries: protectedProcedure.query(async () => {
getCountries: publicProcedure.query(async () => {
const {
data: { result: countries },
} = await printfulApi.get<PrintfulGetCountriesRes>('/countries')