nitro-web 0.1.4 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,25 +1,29 @@
1
- import { Topbar, Field, FormError, Button, request, onChange, getResponseErrors, showError } from 'nitro-web'
2
- import { Errors } from 'nitro-web/types'
1
+ import { Topbar, Field, FormError, Button, request, onChange, getResponseErrors, getSignoutStore, getInitialStore } from 'nitro-web'
2
+ import { Config, Errors } from 'nitro-web/types'
3
+ import { twMerge } from 'nitro-web/util'
3
4
  import { Fragment, useEffect } from 'react'
4
5
 
5
6
  type InviteConfirmProps = {
6
7
  className?: string,
7
8
  elements?: { Button?: typeof Button, Header?: React.ReactNode },
8
9
  redirectTo?: string,
10
+ config: Pick<Config, 'getSignoutStore'>
9
11
  }
10
12
 
11
- export function InviteConfirm({ className, elements, redirectTo }: InviteConfirmProps) {
13
+ export function InviteConfirm({ className, elements, redirectTo, config }: InviteConfirmProps) {
12
14
  const navigate = useNavigate()
13
15
  const params = useParams()
14
- const [store, setStore] = useTracked()
16
+ const [, setStore] = useTracked()
17
+ const getSignoutStoreFn = config.getSignoutStore || getSignoutStore
15
18
  const [isLoading, setIsLoading] = useState(false)
16
- const [accepted, setAccepted] = useState(false)
19
+ const [isExistingUser, setIsExistingUser] = useState<boolean | 'pending'>('pending')
20
+ const [isAccepted, setIsAccepted] = useState(false)
17
21
  const [state, setState] = useState(() => ({
18
22
  firstName: '',
19
23
  lastName: '',
20
24
  password: '',
21
25
  password2: '',
22
- token: params.token,
26
+ email: '',
23
27
  errors: [] as Errors,
24
28
  }))
25
29
 
@@ -28,45 +32,56 @@ export function InviteConfirm({ className, elements, redirectTo }: InviteConfirm
28
32
  Header: elements?.Header || null,
29
33
  }
30
34
 
31
- // Auto-confirm on mount for already signed-in users
35
+ // Get invite details on mount
32
36
  useEffect(() => {
33
- if (store.user) submit({ token: params.token })
37
+ preSubmit()
34
38
  }, [])
35
39
 
40
+ async function preSubmit() {
41
+ try {
42
+ const result = await request(`get /api/invite-pre-confirm/${params.token}`)
43
+ setIsExistingUser(result.isExistingUser)
44
+ setState((s) => ({ ...s, email: result.email }))
45
+ if (result.isExistingUser) submit({ token: params.token })
46
+ } catch (e) {
47
+ setState((s) => ({ ...s, errors: getResponseErrors(e) }))
48
+ }
49
+ }
50
+
36
51
  async function submit(data: object, event?: React.FormEvent<HTMLFormElement>) {
37
52
  try {
38
53
  if (isLoading) return
39
- const result = await request('post /api/invite-confirm', data, event, setIsLoading, setState)
40
- setStore((s) => ({ ...s, ...result }))
41
- setAccepted(true)
54
+ const result = await request(`post /api/invite-confirm/${params.token}`, data, event, setIsLoading, setState)
55
+ // Only update the store if the user was created AND refreshly signed in
56
+ if (result?.jwt) setStore((s) => ({ ...getSignoutStoreFn(s, getInitialStore()), ...result }))
57
+ setIsAccepted(true)
42
58
  setTimeout(() => navigate(redirectTo || '/'), 5000)
43
59
  } catch (e) {
44
- showError(setStore, e)
45
60
  setState((s) => ({ ...s, errors: getResponseErrors(e) }))
46
61
  }
47
62
  }
48
63
 
49
- if (store.user) {
64
+ if (isExistingUser || isAccepted) {
50
65
  return (
51
- <div className={className}>
52
- <div class="py-12 text-center">
53
- {accepted ? (
54
- <Fragment>
55
- <p class="text-lg font-semibold">Your invite has been accepted.</p>
56
- <p class="text-sm text-gray-500 mt-1">You&apos;ll be redirected back to the <Link to="/">home page</Link> shortly...</p>
57
- </Fragment>
58
- ) : isLoading ? (
59
- <Fragment>
60
- <p class="text-lg font-semibold">Accepting your invite...</p>
61
- <p class="text-sm text-gray-500 mt-1">Please wait while we confirm your invite.</p>
62
- </Fragment>
63
- ) : (
64
- <Fragment>
65
- <p class="text-lg font-semibold mb-2">Oops! Something went wrong.</p>
66
- <span class="text-sm text-red-500 bg-red-50 p-1 rounded-md mt-1">{state.errors.map((error) => error.detail).join(', ')}</span>
67
- </Fragment>
68
- )}
69
- </div>
66
+ <div className={twMerge('min-h-[250px]', className)}>
67
+ {isAccepted ? (
68
+ <Fragment>
69
+ <div class="text-2xl font-bold mb-4">Your invite has been accepted.</div>
70
+ <p class="">You&apos;ll be redirected back to the <Link to="/">home page</Link> shortly...</p>
71
+ </Fragment>
72
+ ) : isExistingUser === 'pending' && !state.errors.length ? (
73
+ <Fragment>
74
+ <div class="text-2xl font-bold mb-4">One moment please...</div>
75
+ <p class="">Verifying your token.</p>
76
+ </Fragment>
77
+ ) : (
78
+ <Fragment>
79
+ <div class="text-2xl font-bold mb-4">Something went wrong.</div>
80
+ {state.errors.map((error, i) => {
81
+ return (<span key={i} class="text-red-500 bg-red-50 p-1 rounded-md">{error.detail} <br /></span>)
82
+ })}
83
+ </Fragment>
84
+ )}
70
85
  </div>
71
86
  )
72
87
  }
@@ -74,19 +89,25 @@ export function InviteConfirm({ className, elements, redirectTo }: InviteConfirm
74
89
  return (
75
90
  <div className={className}>
76
91
  {!!Elements.Header && Elements.Header}
77
- <Topbar title={<Fragment>Accept Your Invite</Fragment>} />
92
+ <Topbar title={<Fragment>Accept Invitation</Fragment>} />
78
93
 
79
94
  <form onSubmit={(e) => submit(state, e)} class="mb-0">
80
95
  <div class="grid grid-cols-2 gap-6">
81
96
  <div>
82
97
  <label for="firstName">First Name</label>
83
- <Field name="firstName" type="text" state={state} onChange={(e) => onChange(e, setState)} placeholder="Your first name..." />
98
+ <Field name="firstName" type="text" state={state} onChange={(e) => onChange(e, setState)} placeholder="Your first name..."
99
+ autoComplete="given-name" />
84
100
  </div>
85
101
  <div>
86
102
  <label for="lastName">Last Name</label>
87
- <Field name="lastName" type="text" state={state} onChange={(e) => onChange(e, setState)} placeholder="Your last name..." />
103
+ <Field name="lastName" type="text" state={state} onChange={(e) => onChange(e, setState)} placeholder="Your last name..."
104
+ autoComplete="off" />
88
105
  </div>
89
106
  </div>
107
+ <div>
108
+ <label for="email">Email Address</label>
109
+ <Field name="email" type="email" state={state} placeholder="Your email address..." disabled={true} />
110
+ </div>
90
111
  <div>
91
112
  <label for="password">Choose a Password</label>
92
113
  <Field name="password" type="password" state={state} onChange={(e) => onChange(e, setState)} />
@@ -97,8 +118,7 @@ export function InviteConfirm({ className, elements, redirectTo }: InviteConfirm
97
118
  </div>
98
119
 
99
120
  <div class="mb-14">
100
- Already have an account? <Link to="/signin" class="underline2 is-active">Sign in here</Link> first then revisit this link.
101
- <FormError state={state} className="pt-2" />
121
+ <FormError state={state} className="pt-2" fields={['firstName', 'lastName', 'password', 'password2']} />
102
122
  </div>
103
123
 
104
124
  <Elements.Button class="w-full" isLoading={isLoading} type="submit">Accept Invite & Create Account</Elements.Button>
@@ -1,5 +1,5 @@
1
- import { Topbar, Field, FormError, Button, request, onChange } from 'nitro-web'
2
- import { Errors } from 'nitro-web/types'
1
+ import { Topbar, Field, FormError, Button, request, onChange, getSignoutStore, getInitialStore } from 'nitro-web'
2
+ import { Config, Errors } from 'nitro-web/types'
3
3
  import { Fragment } from 'react'
4
4
 
5
5
  type resetInstructionsProps = {
@@ -8,6 +8,10 @@ type resetInstructionsProps = {
8
8
  redirectTo?: string,
9
9
  }
10
10
 
11
+ type resetPasswordProps = resetInstructionsProps & {
12
+ config: Pick<Config, 'getSignoutStore'>
13
+ }
14
+
11
15
  export function ResetInstructions({ className, elements, redirectTo }: resetInstructionsProps) {
12
16
  const navigate = useNavigate()
13
17
  const [isLoading, setIsLoading] = useState(false)
@@ -52,9 +56,10 @@ export function ResetInstructions({ className, elements, redirectTo }: resetInst
52
56
  )
53
57
  }
54
58
 
55
- export function ResetPassword({ className, elements, redirectTo }: resetInstructionsProps) {
59
+ export function ResetPassword({ className, elements, redirectTo, config }: resetPasswordProps) {
56
60
  const navigate = useNavigate()
57
61
  const params = useParams()
62
+ const getSignoutStoreFn = config.getSignoutStore || getSignoutStore
58
63
  const [isLoading, setIsLoading] = useState(false)
59
64
  const [, setStore] = useTracked()
60
65
  const [state, setState] = useState(() => ({
@@ -73,7 +78,7 @@ export function ResetPassword({ className, elements, redirectTo }: resetInstruct
73
78
  try {
74
79
  if (isLoading) return
75
80
  const data = await request('post /api/reset-password', state, event, setIsLoading, setState)
76
- setStore((s) => ({ ...s, ...data }))
81
+ setStore((s) => ({ ...getSignoutStoreFn(s, getInitialStore()), ...data }))
77
82
  setTimeout(() => navigate(redirectTo || '/'), 10) // wait for setStore
78
83
  } catch (e) {
79
84
  return setState({ ...state, errors: e as Errors })
@@ -1,5 +1,8 @@
1
- import { Topbar, Field, Button, FormError, request, queryObject, injectedConfig, updateJwt, onChange } from 'nitro-web'
2
- import { Errors } from 'nitro-web/types'
1
+ import {
2
+ Topbar, Field, Button, FormError, request, queryObject, injectedConfig, updateJwt, onChange,
3
+ getSignoutStore, getInitialStore,
4
+ } from 'nitro-web'
5
+ import { Config, Errors } from 'nitro-web/types'
3
6
  import { Fragment } from 'react'
4
7
 
5
8
  type signinProps = {
@@ -7,12 +10,14 @@ type signinProps = {
7
10
  elements?: { Button?: typeof Button, Header?: React.ReactNode },
8
11
  redirectTo?: string,
9
12
  hideSignup?: boolean,
13
+ config: Pick<Config, 'getSignoutStore'>
10
14
  }
11
15
 
12
- export function Signin({ className, elements, redirectTo, hideSignup }: signinProps) {
16
+ export function Signin({ className, elements, redirectTo, hideSignup, config }: signinProps) {
13
17
  const navigate = useNavigate()
14
18
  const location = useLocation()
15
19
  const isSignout = location.pathname == '/signout'
20
+ const getSignoutStoreFn = config.getSignoutStore || getSignoutStore
16
21
  const [isLoading, setIsLoading] = useState(isSignout)
17
22
  const [, setStore] = useTracked()
18
23
  const [state, setState] = useState({
@@ -32,9 +37,11 @@ export function Signin({ className, elements, redirectTo, hideSignup }: signinPr
32
37
  if (query.email) setState({ ...state, email: query.email as string })
33
38
  }, [location.search])
34
39
 
40
+
35
41
  useEffect(() => {
36
42
  if (isSignout) {
37
- setStore((s) => ({ ...s, user: undefined }))
43
+ // Reset the user to the initialStoreData user
44
+ setStore((s) => getSignoutStoreFn(s, getInitialStore()))
38
45
  // util.axios().get('/api/signout')
39
46
  Promise.resolve()
40
47
  .then(() => setIsLoading(false))
@@ -50,7 +57,7 @@ export function Signin({ className, elements, redirectTo, hideSignup }: signinPr
50
57
  const data = await request('post /api/signin', state, e, setIsLoading, setState)
51
58
  // Keep it loading until we navigate
52
59
  setIsLoading(true)
53
- setStore((s) => ({ ...s, ...data }))
60
+ setStore((s) => ({ ...getSignoutStoreFn(s, getInitialStore()), ...data }))
54
61
  setTimeout(() => { // wait for setStore
55
62
  if (location.search.includes('redirect')) navigate(location.search.replace('?redirect=', ''))
56
63
  else navigate(redirectTo || '/')
@@ -1,17 +1,19 @@
1
- import { Button, Field, FormError, Topbar, request, injectedConfig, onChange } from 'nitro-web'
2
- import { Errors } from 'nitro-web/types'
1
+ import { Button, Field, FormError, Topbar, request, injectedConfig, onChange, getSignoutStore, getInitialStore } from 'nitro-web'
2
+ import { Config, Errors } from 'nitro-web/types'
3
3
  import { Fragment } from 'react'
4
4
 
5
5
  type signupProps = {
6
6
  className?: string,
7
7
  elements?: { Button?: typeof Button, Header?: React.ReactNode },
8
8
  redirectTo?: string,
9
+ config: Pick<Config, 'getSignoutStore'>
9
10
  }
10
11
 
11
- export function Signup({ className, elements, redirectTo }: signupProps) {
12
+ export function Signup({ className, elements, redirectTo, config }: signupProps) {
12
13
  const navigate = useNavigate()
13
14
  const [isLoading, setIsLoading] = useState(false)
14
15
  const [, setStore] = useTracked()
16
+ const getSignoutStoreFn = config.getSignoutStore || getSignoutStore
15
17
  const [state, setState] = useState({
16
18
  email: injectedConfig.env === 'development' ? (injectedConfig.placeholderEmail || '') : '',
17
19
  name: injectedConfig.env === 'development' ? 'Bruce Wayne' : '',
@@ -29,7 +31,7 @@ export function Signup({ className, elements, redirectTo }: signupProps) {
29
31
  try {
30
32
  if (isLoading) return
31
33
  const data = await request('post /api/signup', state, e, setIsLoading, setState)
32
- setStore((prev) => ({ ...prev, ...data }))
34
+ setStore((s) => ({ ...getSignoutStoreFn(s, getInitialStore()), ...data }))
33
35
  setTimeout(() => navigate(redirectTo || '/'), 10) // wait for setStore
34
36
  } catch (e) {
35
37
  setState((prev) => ({ ...prev, errors: e as Errors }))
@@ -164,7 +164,7 @@ export const Dropdown = forwardRef(function Dropdown({
164
164
  ' nitro-dropdown' +
165
165
  (className ? ` ${className}` : '')
166
166
  }
167
- onClick={(e) => e.stopPropagation()} // required for dropdowns inside row links
167
+ onClick={(e) => { e.stopPropagation(); e.preventDefault() }} // required for dropdowns inside row links
168
168
  ref={dropdownRef}
169
169
  css={style}
170
170
  >
@@ -1,7 +1,7 @@
1
1
  import { JSX, useState, useCallback, Fragment, useMemo, useEffect } from 'react'
2
2
  import { ChevronDownIcon, ChevronUpIcon } from 'lucide-react'
3
3
  import { Checkbox, queryObject, queryString, Spinner, twMerge, LoadingWithDots, LoadingOverlay } from 'nitro-web'
4
- import { useLocation, useNavigate } from 'react-router-dom'
4
+ import { useLocation, useNavigate, Link } from 'react-router-dom'
5
5
 
6
6
  export type TableRowType = 'row' | 'loading' | 'empty' | 'thead'
7
7
 
@@ -36,6 +36,7 @@ export type TableProps<T> = {
36
36
  rowSideColor?: (row: T|undefined, type: TableRowType) => { className: string, width: number }
37
37
  rowGap?: number
38
38
  rowOnClick?: (row: T) => void
39
+ rowLink?: (row: T) => string
39
40
  columnGap?: number
40
41
  columnPaddingX?: number
41
42
  className?: string
@@ -67,6 +68,7 @@ export function Table<T extends TableRow>({
67
68
  rowSideColor,
68
69
  rowGap=0,
69
70
  rowOnClick,
71
+ rowLink,
70
72
  columnGap=11,
71
73
  columnPaddingX=11,
72
74
  // Class names
@@ -244,97 +246,97 @@ export function Table<T extends TableRow>({
244
246
  {
245
247
  rowsToRender.map((row: T, i: number) => {
246
248
  const isSelected = selectedRowIds.includes(row._id || '')
249
+ const Element = (rowLink ? Link : 'div') as React.ElementType
250
+ const extraProps = rowLink ? { to: rowLink(row) } : { onClick: rowOnClick ? () => rowOnClick(row) : undefined }
247
251
  return (
248
- <div
252
+ <Element
253
+ {...extraProps}
249
254
  key={`${row._id}-${i}`}
250
255
  id={`row-${row._id}-${i}`}
251
- onClick={rowOnClick ? () => rowOnClick(row) : undefined}
252
256
  className={twMerge(
253
- `table-row relative ${rowOnClick ? 'cursor-pointer' : ''} ${isSelected ? 'is-selected' : ''}`,
257
+ `table-row relative ${(rowOnClick || rowLink) ? 'cursor-pointer' : ''} ${isSelected ? 'is-selected' : ''}`,
254
258
  rowClassName,
255
259
  rowClassNameFn ? rowClassNameFn(row, i) : ''
256
260
  )}
257
261
  >
258
- {
259
- columns.map((col, j) => {
260
- const rowType = row._id ? 'row' : isLoading ? 'loading' : 'empty'
261
- const { pl, pr, sideColor } = getColumnPadding(j, isLoading ? undefined : row, rowType)
262
- if (col.isHidden) return <Fragment key={j} />
263
- return (
264
- <div
265
- key={j}
266
- style={{ height: rowHeightMin, paddingLeft: pl, paddingRight: pr }}
262
+ {columns.map((col, j) => {
263
+ const rowType = row._id ? 'row' : isLoading ? 'loading' : 'empty'
264
+ const { pl, pr, sideColor } = getColumnPadding(j, isLoading ? undefined : row, rowType)
265
+ if (col.isHidden) return <Fragment key={j} />
266
+ return (
267
+ <div
268
+ key={j}
269
+ style={{ height: rowHeightMin, paddingLeft: pl, paddingRight: pr }}
270
+ className={twMerge(
271
+ _columnClassName,
272
+ getAlignClass(col.align),
273
+ columnClassName,
274
+ columnClassNameFn ? columnClassNameFn(col, row, i) : '',
275
+ col.className,
276
+ isSelected ? `bg-gray-50 ${columnSelectedClassName||''}` : ''
277
+ )}
278
+ >
279
+ <div
280
+ // pl:sideColorPadding was originally here
281
+ style={{ maxHeight: rowContentHeightMax }}
267
282
  className={twMerge(
268
- _columnClassName,
269
- getAlignClass(col.align),
270
- columnClassName,
271
- columnClassNameFn ? columnClassNameFn(col, row, i) : '',
272
- col.className,
273
- isSelected ? `bg-gray-50 ${columnSelectedClassName||''}` : ''
283
+ rowContentHeightMax ? 'overflow-hidden' : '',
284
+ getLineClampClassName(col.rowLinesMax),
285
+ col.overflow ? 'overflow-visible' : '',
286
+ col.innerClassName
274
287
  )}
275
288
  >
276
- <div
277
- // pl:sideColorPadding was originally here
278
- style={{ maxHeight: rowContentHeightMax }}
279
- className={twMerge(
280
- rowContentHeightMax ? 'overflow-hidden' : '',
281
- getLineClampClassName(col.rowLinesMax),
282
- col.overflow ? 'overflow-visible' : '',
283
- col.innerClassName
284
- )}
285
- >
286
- {
287
- // Side color
288
- sideColor &&
289
- <div
290
- className={`absolute top-0 left-0 h-full ${sideColor?.className||''}`}
291
- style={{ width: sideColor.width }}
292
- />
293
- }
294
- {
295
- // Rows (content hidden when loading inline)
296
- row._id &&
297
- <div className={isLoading && showLoadingInline ? 'opacity-0 pointer-events-none' : ''}>
298
- {
299
- col.value == 'checkbox'
300
- ? <Checkbox
301
- size={checkboxSize}
302
- name={`checkbox-${row._id}`}
303
- onChange={(e) => onSelect(row?._id || '', e.target.checked)}
304
- checked={selectedRowIds.includes(row?._id || '')}
305
- onClick={(e) => e.stopPropagation()}
306
- hitboxPadding={5}
307
- className='!m-0 py-[5px]' // py-5 is required for hitbox (restricted to tabel cell height)
308
- checkboxClassName={twMerge('border-foreground shadow-[0_1px_2px_0px_#0000001c]', checkboxClassName)}
309
- />
310
- : generateTd(col, row, i, i == rows.length - 1)
311
- }
312
- </div>
313
- }
314
- {
315
- // Show "no records" or "loading" text in the first column
316
- j == 0 && (!row._id || isLoading) &&
317
- <div className={'absolute top-0 h-full flex items-center justify-center gap-3 text-sm text-gray-500'}>
318
- {
319
- (!row._id && !isLoading) ? (
320
- 'No records found.'
321
- ) : (!row._id && isLoading && showLoadingInline === true) ? (
322
- <Fragment>
323
- <Spinner className="border-gray-500" />
324
- <LoadingWithDots message={loadingMessage} />
325
- </Fragment>
326
- ) : (!row._id && isLoading && showLoadingInline) ? (
327
- showLoadingInline
328
- ) : null
329
- }
330
- </div>
331
- }
332
- </div>
289
+ {
290
+ // Side color
291
+ sideColor &&
292
+ <div
293
+ className={`absolute top-0 left-0 h-full ${sideColor?.className||''}`}
294
+ style={{ width: sideColor.width }}
295
+ />
296
+ }
297
+ {
298
+ // Rows (content hidden when loading inline)
299
+ row._id &&
300
+ <div className={isLoading && showLoadingInline ? 'opacity-0 pointer-events-none' : ''}>
301
+ {
302
+ col.value == 'checkbox'
303
+ ? <Checkbox
304
+ size={checkboxSize}
305
+ name={`checkbox-${row._id}`}
306
+ onChange={(e) => onSelect(row?._id || '', e.target.checked)}
307
+ checked={selectedRowIds.includes(row?._id || '')}
308
+ onClick={(e) => e.stopPropagation()}
309
+ hitboxPadding={5}
310
+ className='!m-0 py-[5px]' // py-5 is required for hitbox (restricted to tabel cell height)
311
+ checkboxClassName={twMerge('border-foreground shadow-[0_1px_2px_0px_#0000001c]', checkboxClassName)}
312
+ />
313
+ : generateTd(col, row, i, i == rows.length - 1)
314
+ }
315
+ </div>
316
+ }
317
+ {
318
+ // Show "no records" or "loading" text in the first column
319
+ j == 0 && (!row._id || isLoading) &&
320
+ <div className={'absolute top-0 h-full flex items-center justify-center gap-3 text-sm text-gray-500'}>
321
+ {
322
+ (!row._id && !isLoading) ? (
323
+ 'No records found.'
324
+ ) : (!row._id && isLoading && showLoadingInline === true) ? (
325
+ <Fragment>
326
+ <Spinner className="border-gray-500" />
327
+ <LoadingWithDots message={loadingMessage} />
328
+ </Fragment>
329
+ ) : (!row._id && isLoading && showLoadingInline) ? (
330
+ showLoadingInline
331
+ ) : null
332
+ }
333
+ </div>
334
+ }
333
335
  </div>
334
- )
335
- })
336
- }
337
- </div>
336
+ </div>
337
+ )
338
+ })}
339
+ </Element>
338
340
  )
339
341
  })
340
342
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nitro-web",
3
- "version": "0.1.4",
3
+ "version": "0.2.1",
4
4
  "repository": "github:boycce/nitro-web",
5
5
  "homepage": "https://boycce.github.io/nitro-web/",
6
6
  "description": "Nitro is a battle-tested, modular base project to turbocharge your projects, styled using Tailwind 🚀",
@@ -13,6 +13,10 @@ let templates = {}
13
13
  let nodemailerMailgun = undefined
14
14
  const _dirname = dirname(fileURLToPath(import.meta.url)) + '/'
15
15
 
16
+ export const requiredEmailConfigKeys = ['baseUrl', 'emailFrom', 'name', 'env']
17
+ export const optionalEmailConfigKeys = ['emailReplyTo', 'emailTestMode', 'mailgunDomain', 'mailgunKey']
18
+
19
+
16
20
  /**
17
21
  * Sends an email using a predefined template, with optional data/or recipientVariables
18
22
  * @typedef {{ baseUrl?: string, emailFrom?: string, mailgunDomain?: string, mailgunKey?: string, name?: string }} Config
@@ -44,24 +48,19 @@ export async function sendEmail({
44
48
  skipCssInline,
45
49
  test,
46
50
  }) {
47
- if (!config) {
48
- throw new Error('sendEmail: `config` missing')
49
- } else if (!config.baseUrl) {
50
- throw new Error('sendEmail: `config.baseUrl` is missing')
51
- } else if (!config.emailFrom) {
52
- throw new Error('sendEmail: `config.emailFrom` is missing')
53
- } else if (!test && (!config.mailgunKey || !config.mailgunDomain)) {
54
- throw new Error('sendEmail: `config.mailgunKey` or `config.mailgunDomain` is missing')
55
- } else if (!config.name) {
56
- throw new Error('sendEmail: `config.name` is missing')
57
- } else if (!template) {
58
- throw new Error('sendEmail: `template` missing')
59
- } else if (!to) {
60
- throw new Error('sendEmail: `to` missing')
51
+ const isTest = config.emailTestMode || test
52
+ if (!config) throw new Error('sendEmail: `config` missing')
53
+ for (const key of requiredEmailConfigKeys) {
54
+ if (!config[key]) throw new Error(`sendEmail: config.${key} is missing`)
55
+ }
56
+ if (!isTest && (!config.mailgunKey || !config.mailgunDomain)) {
57
+ throw new Error('sendEmail: config.mailgunKey and config.mailgunDomain are required')
61
58
  }
59
+ if (!template) throw new Error('sendEmail: `template` missing')
60
+ if (!to) throw new Error('sendEmail: `to` missing')
62
61
 
63
62
  // Setup nodemailer once
64
- if (!nodemailerMailgun && !test) {
63
+ if (!nodemailerMailgun && !isTest) {
65
64
  nodemailerMailgun = nodemailer.createTransport(
66
65
  mailgun({ auth: { api_key: config.mailgunKey, domain: config.mailgunDomain }})
67
66
  )
@@ -95,13 +94,13 @@ export async function sendEmail({
95
94
  bcc: bcc,
96
95
  emailTemplateDir: getDirectories(path, config.pwd).emailTemplateDir,
97
96
  from: from,
98
- isDev: config.baseUrl.match(/:/), // possibly use config.env here
97
+ isDev: config.env === 'development',
99
98
  recipientVariables: recipientVariables,
100
99
  replyTo: replyTo,
101
100
  skipCssInline: skipCssInline,
102
101
  subject: subject,
103
102
  template: template,
104
- test: config.emailTestMode || test,
103
+ test: isTest,
105
104
  to: to,
106
105
  url: config.baseUrl,
107
106
  }
@@ -187,24 +186,27 @@ function processTemplate(settings, html) {
187
186
  async function sendWithMailgun(settings, html) {
188
187
  // Supports batch sending via recipientVariables, limit 1000 emails
189
188
  // https://documentation.mailgun.com/en/latest/user_manual.html?highlight=batch%20sending#batch-sending
190
- let processedhtml = await processTemplate(settings, html)
191
- // console.log(settings)
189
+ const processedhtml = await processTemplate(settings, html)
190
+ const mailgunOpts = {
191
+ ...(settings.bcc && !settings.isDev? { bcc: settings.bcc } : {}),
192
+ from: settings.from,
193
+ html: processedhtml,
194
+ 'h:Reply-To': settings.replyTo,
195
+ subject: settings.subject,
196
+ to: settings.to,
197
+ ...(!settings.recipientVariables? {} : {
198
+ 'recipient-variables': typeof settings.recipientVariables == 'string'
199
+ ? settings.recipientVariables
200
+ : JSON.stringify(settings.recipientVariables),
201
+ }),
202
+ }
203
+ if (settings.test && settings.isDev) {
204
+ console.info('Test mode: sendEmail mailgunOpts', { ...mailgunOpts, html: null, 'recipient-variables': settings.recipientVariables })
205
+ }
192
206
  if (settings.test) return processedhtml
193
207
 
194
208
  return new Promise((resolve, reject) => {
195
- nodemailerMailgun.sendMail({
196
- ...(settings.bcc && !settings.isDev? { bcc: settings.bcc } : {}),
197
- from: settings.from,
198
- html: processedhtml,
199
- 'h:Reply-To': settings.replyTo,
200
- subject: settings.subject,
201
- to: settings.to,
202
- ...(!settings.recipientVariables? {} : {
203
- 'recipient-variables': typeof settings.recipientVariables == 'string'
204
- ? settings.recipientVariables
205
- : JSON.stringify(settings.recipientVariables),
206
- }),
207
- }, function(err, info) {
209
+ nodemailerMailgun.sendMail(mailgunOpts, function(err, info) {
208
210
  if (err) {
209
211
  console.error('SendEmail mailgun error')
210
212
  reject(err)
package/server/index.js CHANGED
@@ -24,7 +24,7 @@ export { userModel, companyModel, setupDefaultModels }
24
24
  export { setupRouter, middleware, isValidUserOrRespond, isAdminUser } from './router.js'
25
25
 
26
26
  // Export email utility
27
- export { sendEmail } from './email/index.js'
27
+ export { sendEmail, requiredEmailConfigKeys, optionalEmailConfigKeys } from './email/index.js'
28
28
 
29
29
  // Export API controllers
30
30
  export * from '../components/auth/auth.api.js'
@@ -21,6 +21,7 @@ export default {
21
21
  }],
22
22
  invites: [{
23
23
  email: { type: 'email', required: true },
24
+ firstName: { type: 'string', required: true },
24
25
  role: { type: 'string', enum: ['owner', 'manager'], required: true },
25
26
  inviteToken: { type: 'string', required: true },
26
27
  }],
@@ -57,8 +58,8 @@ export default {
57
58
  publicData: function(models) {
58
59
  return models
59
60
  },
60
- loginPopulate: function() {
61
- // return the company with expanded company.users
61
+ authPopulate: function() {
62
+ // Special method called by auth.api.js on authentication.
62
63
  return [
63
64
  {
64
65
  as: 'usersExpanded',