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.
- package/client/app.tsx +5 -5
- package/client/index.ts +1 -1
- package/client/store.ts +15 -5
- package/components/auth/auth.api.js +304 -117
- package/components/auth/inviteConfirm.tsx +57 -37
- package/components/auth/reset.tsx +9 -4
- package/components/auth/signin.tsx +12 -5
- package/components/auth/signup.tsx +6 -4
- package/components/partials/element/dropdown.tsx +1 -1
- package/components/partials/element/table.tsx +82 -80
- package/package.json +1 -1
- package/server/email/index.js +34 -32
- package/server/index.js +1 -1
- package/server/models/company.js +3 -2
- package/server/models/user.js +2 -1
- package/server/router.js +6 -5
- package/types/components/auth/auth.api.d.ts +40 -11
- package/types/components/auth/auth.api.d.ts.map +1 -1
- package/types/server/email/index.d.ts +2 -0
- package/types/server/email/index.d.ts.map +1 -1
- package/types/server/index.d.ts +1 -1
- package/types/server/index.d.ts.map +1 -1
- package/types/server/models/company.d.ts +5 -1
- package/types/server/models/company.d.ts.map +1 -1
- package/types/server/models/user.d.ts +1 -1
- package/types/server/models/user.d.ts.map +1 -1
- package/types/util.d.ts +2 -2
- package/types/util.d.ts.map +1 -1
- package/types.ts +2 -1
- package/util.js +4 -3
|
@@ -1,25 +1,29 @@
|
|
|
1
|
-
import { Topbar, Field, FormError, Button, request, onChange, getResponseErrors,
|
|
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 [
|
|
16
|
+
const [, setStore] = useTracked()
|
|
17
|
+
const getSignoutStoreFn = config.getSignoutStore || getSignoutStore
|
|
15
18
|
const [isLoading, setIsLoading] = useState(false)
|
|
16
|
-
const [
|
|
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
|
-
|
|
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
|
-
//
|
|
35
|
+
// Get invite details on mount
|
|
32
36
|
useEffect(() => {
|
|
33
|
-
|
|
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(
|
|
40
|
-
|
|
41
|
-
|
|
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 (
|
|
64
|
+
if (isExistingUser || isAccepted) {
|
|
50
65
|
return (
|
|
51
|
-
<div className={className}>
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
<
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
<
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
<
|
|
65
|
-
|
|
66
|
-
<span class="text-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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'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
|
|
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
|
-
|
|
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 }:
|
|
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 {
|
|
2
|
-
|
|
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
|
-
|
|
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((
|
|
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
|
-
<
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
277
|
-
//
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
</
|
|
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
|
|
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 🚀",
|
package/server/email/index.js
CHANGED
|
@@ -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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
throw new Error(
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
|
|
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 && !
|
|
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.
|
|
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:
|
|
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
|
-
|
|
191
|
-
|
|
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'
|
package/server/models/company.js
CHANGED
|
@@ -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
|
-
|
|
61
|
-
//
|
|
61
|
+
authPopulate: function() {
|
|
62
|
+
// Special method called by auth.api.js on authentication.
|
|
62
63
|
return [
|
|
63
64
|
{
|
|
64
65
|
as: 'usersExpanded',
|