@startsimpli/auth 0.4.16 → 0.4.18
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/package.json +17 -14
- package/src/__tests__/auth-client-oauth-register.test.ts +5 -8
- package/src/__tests__/auth-functions.test.ts +0 -1
- package/src/__tests__/useauth-shape-contract.test.ts +0 -1
- package/src/client/__tests__/mock-backend.test.ts +3 -6
- package/src/client/auth-client.ts +59 -2
- package/src/client/auth-context.tsx +62 -7
- package/src/client/backend.ts +10 -1
- package/src/client/functions.ts +31 -5
- package/src/client/mock-backend.ts +0 -3
- package/src/client/use-auth.ts +6 -1
- package/src/components/forgot-password-form.tsx +97 -0
- package/src/components/index.ts +5 -1
- package/src/components/oauth-callback.tsx +5 -2
- package/src/components/reset-password-form.tsx +124 -0
- package/src/components/sign-in-form.tsx +125 -0
- package/src/components/signup-form.tsx +161 -0
- package/src/components/use-oauth-callback.ts +14 -2
- package/src/hooks/__tests__/use-domain-claims.test.tsx +95 -0
- package/src/hooks/__tests__/use-invitations.test.tsx +90 -0
- package/src/hooks/__tests__/use-membership-from-api.test.tsx +45 -0
- package/src/hooks/__tests__/use-membership.test.tsx +136 -0
- package/src/hooks/index.ts +39 -0
- package/src/hooks/use-domain-claims.ts +144 -0
- package/src/hooks/use-invitations.ts +138 -0
- package/src/hooks/use-membership-from-api.ts +99 -0
- package/src/hooks/use-membership.ts +192 -0
- package/src/index.ts +27 -0
- package/src/server/index.ts +4 -0
- package/src/types/index.ts +0 -1
- package/src/utils/central-auth.ts +91 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/validation.ts +10 -21
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ResetPasswordForm — shared password-reset form.
|
|
5
|
+
*
|
|
6
|
+
* The reset TOKEN comes from a query string the consumer reads (next/router
|
|
7
|
+
* differs across app versions); the form just takes it as a prop.
|
|
8
|
+
*
|
|
9
|
+
* Same one-edit-everywhere principle as SignupForm: future password-policy
|
|
10
|
+
* changes happen HERE, not in each app's /auth/reset-password/page.tsx.
|
|
11
|
+
* startsim-j29.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { useState } from 'react'
|
|
15
|
+
import { resetPassword } from '../client/functions'
|
|
16
|
+
|
|
17
|
+
export interface ResetPasswordFormProps {
|
|
18
|
+
/** The reset token from the URL (e.g. ?token=…). Consumer reads it from
|
|
19
|
+
* useSearchParams / useRouter and hands it in. */
|
|
20
|
+
token: string
|
|
21
|
+
/** Optional email if the API requires it alongside the token. */
|
|
22
|
+
email?: string
|
|
23
|
+
/** Called after a successful reset. Apps typically router.replace('/login'). */
|
|
24
|
+
onSuccess?: () => void
|
|
25
|
+
/** Optional override for the submit handler. Defaults to
|
|
26
|
+
* resetPassword({ token, password, email? }) from @startsimpli/auth. */
|
|
27
|
+
onSubmit?: (payload: { token: string; password: string; email?: string }) => Promise<void>
|
|
28
|
+
/** Rendered in place of the form when no token is present. */
|
|
29
|
+
invalidTokenMessage?: React.ReactNode
|
|
30
|
+
minPasswordLength?: number
|
|
31
|
+
submitLabel?: string
|
|
32
|
+
submittingLabel?: string
|
|
33
|
+
classNames?: ResetPasswordFormClassNames
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ResetPasswordFormClassNames {
|
|
37
|
+
form?: string
|
|
38
|
+
fieldRow?: string
|
|
39
|
+
label?: string
|
|
40
|
+
input?: string
|
|
41
|
+
errorText?: string
|
|
42
|
+
submitButton?: string
|
|
43
|
+
invalidTokenText?: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const DEFAULTS: Required<ResetPasswordFormClassNames> = {
|
|
47
|
+
form: 'space-y-4',
|
|
48
|
+
fieldRow: '',
|
|
49
|
+
label: 'block text-sm font-medium text-gray-700 mb-1',
|
|
50
|
+
input:
|
|
51
|
+
'w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-primary-500 focus:ring-1 focus:ring-primary-500',
|
|
52
|
+
errorText: 'text-sm text-red-600',
|
|
53
|
+
submitButton:
|
|
54
|
+
'w-full rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-50',
|
|
55
|
+
invalidTokenText: 'text-sm text-red-600',
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function ResetPasswordForm({
|
|
59
|
+
token,
|
|
60
|
+
email,
|
|
61
|
+
onSuccess,
|
|
62
|
+
onSubmit,
|
|
63
|
+
invalidTokenMessage,
|
|
64
|
+
minPasswordLength = 8,
|
|
65
|
+
submitLabel = 'Reset password',
|
|
66
|
+
submittingLabel = 'Saving…',
|
|
67
|
+
classNames,
|
|
68
|
+
}: ResetPasswordFormProps) {
|
|
69
|
+
const [password, setPassword] = useState('')
|
|
70
|
+
const [error, setError] = useState('')
|
|
71
|
+
const [submitting, setSubmitting] = useState(false)
|
|
72
|
+
const cls = { ...DEFAULTS, ...(classNames ?? {}) }
|
|
73
|
+
|
|
74
|
+
if (!token) {
|
|
75
|
+
return (
|
|
76
|
+
<p className={cls.invalidTokenText}>
|
|
77
|
+
{invalidTokenMessage ?? 'This reset link is invalid or has expired.'}
|
|
78
|
+
</p>
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function handleSubmit(e: React.FormEvent) {
|
|
83
|
+
e.preventDefault()
|
|
84
|
+
setError('')
|
|
85
|
+
if (password.length < minPasswordLength) {
|
|
86
|
+
setError(`Password must be at least ${minPasswordLength} characters`)
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
setSubmitting(true)
|
|
90
|
+
try {
|
|
91
|
+
const payload = { token, password, ...(email ? { email } : {}) }
|
|
92
|
+
if (onSubmit) await onSubmit(payload)
|
|
93
|
+
else await resetPassword(payload)
|
|
94
|
+
onSuccess?.()
|
|
95
|
+
} catch (err) {
|
|
96
|
+
setError(err instanceof Error ? err.message : 'Could not reset password')
|
|
97
|
+
} finally {
|
|
98
|
+
setSubmitting(false)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return (
|
|
103
|
+
<form onSubmit={handleSubmit} className={cls.form}>
|
|
104
|
+
<div className={cls.fieldRow}>
|
|
105
|
+
<label htmlFor="reset-password" className={cls.label}>New password</label>
|
|
106
|
+
<input
|
|
107
|
+
id="reset-password"
|
|
108
|
+
type="password"
|
|
109
|
+
value={password}
|
|
110
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
111
|
+
autoComplete="new-password"
|
|
112
|
+
required
|
|
113
|
+
minLength={minPasswordLength}
|
|
114
|
+
className={cls.input}
|
|
115
|
+
disabled={submitting}
|
|
116
|
+
/>
|
|
117
|
+
</div>
|
|
118
|
+
{error && <p className={cls.errorText}>{error}</p>}
|
|
119
|
+
<button type="submit" disabled={submitting} className={cls.submitButton}>
|
|
120
|
+
{submitting ? submittingLabel : submitLabel}
|
|
121
|
+
</button>
|
|
122
|
+
</form>
|
|
123
|
+
)
|
|
124
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SignInForm — shared headless sign-in (email + password).
|
|
5
|
+
*
|
|
6
|
+
* Same one-edit-everywhere principle as SignupForm/ResetPasswordForm/
|
|
7
|
+
* ForgotPasswordForm: every app's sign-in page is a thin wrapper around
|
|
8
|
+
* this. Future changes (MFA prompt, passkey button row, magic-link toggle)
|
|
9
|
+
* land here, not in N app-local /signin pages.
|
|
10
|
+
*
|
|
11
|
+
* Built for auth-web (startsim-ul0) but immediately reusable by any app
|
|
12
|
+
* that still wants its own local signin while the central host rolls out.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { useState } from 'react'
|
|
16
|
+
import { useAuth } from '../client/use-auth'
|
|
17
|
+
|
|
18
|
+
export interface SignInFormProps {
|
|
19
|
+
/** Called after a successful sign-in. Apps typically router.replace(returnTo). */
|
|
20
|
+
onSuccess?: () => void
|
|
21
|
+
/** Override the auth call. Defaults to useAuth().login(email, password). */
|
|
22
|
+
onSubmit?: (payload: SignInPayload) => Promise<void>
|
|
23
|
+
/** Optional content rendered above the submit button (e.g. "remember me"). */
|
|
24
|
+
extraFields?: React.ReactNode
|
|
25
|
+
/** Optional content rendered below the submit button (e.g. OAuth providers,
|
|
26
|
+
* "Forgot password?" link). */
|
|
27
|
+
belowSubmit?: React.ReactNode
|
|
28
|
+
submitLabel?: string
|
|
29
|
+
submittingLabel?: string
|
|
30
|
+
classNames?: SignInFormClassNames
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface SignInPayload {
|
|
34
|
+
email: string
|
|
35
|
+
password: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface SignInFormClassNames {
|
|
39
|
+
form?: string
|
|
40
|
+
fieldRow?: string
|
|
41
|
+
label?: string
|
|
42
|
+
input?: string
|
|
43
|
+
errorText?: string
|
|
44
|
+
submitButton?: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const DEFAULTS: Required<SignInFormClassNames> = {
|
|
48
|
+
form: 'space-y-4',
|
|
49
|
+
fieldRow: '',
|
|
50
|
+
label: 'block text-sm font-medium text-gray-700 mb-1',
|
|
51
|
+
input:
|
|
52
|
+
'w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-primary-500 focus:ring-1 focus:ring-primary-500',
|
|
53
|
+
errorText: 'text-sm text-red-600',
|
|
54
|
+
submitButton:
|
|
55
|
+
'w-full rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-50',
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function SignInForm({
|
|
59
|
+
onSuccess,
|
|
60
|
+
onSubmit,
|
|
61
|
+
extraFields,
|
|
62
|
+
belowSubmit,
|
|
63
|
+
submitLabel = 'Sign in',
|
|
64
|
+
submittingLabel = 'Signing in…',
|
|
65
|
+
classNames,
|
|
66
|
+
}: SignInFormProps) {
|
|
67
|
+
const auth = useAuth()
|
|
68
|
+
const [email, setEmail] = useState('')
|
|
69
|
+
const [password, setPassword] = useState('')
|
|
70
|
+
const [error, setError] = useState('')
|
|
71
|
+
const [submitting, setSubmitting] = useState(false)
|
|
72
|
+
const cls = { ...DEFAULTS, ...(classNames ?? {}) }
|
|
73
|
+
|
|
74
|
+
async function handleSubmit(e: React.FormEvent) {
|
|
75
|
+
e.preventDefault()
|
|
76
|
+
setError('')
|
|
77
|
+
setSubmitting(true)
|
|
78
|
+
try {
|
|
79
|
+
if (onSubmit) await onSubmit({ email, password })
|
|
80
|
+
else await auth.login(email, password)
|
|
81
|
+
onSuccess?.()
|
|
82
|
+
} catch (err) {
|
|
83
|
+
setError(err instanceof Error ? err.message : 'Could not sign in')
|
|
84
|
+
} finally {
|
|
85
|
+
setSubmitting(false)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<form onSubmit={handleSubmit} className={cls.form}>
|
|
91
|
+
<div className={cls.fieldRow}>
|
|
92
|
+
<label htmlFor="signin-email" className={cls.label}>Email</label>
|
|
93
|
+
<input
|
|
94
|
+
id="signin-email"
|
|
95
|
+
type="email"
|
|
96
|
+
value={email}
|
|
97
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
98
|
+
autoComplete="email"
|
|
99
|
+
required
|
|
100
|
+
className={cls.input}
|
|
101
|
+
disabled={submitting}
|
|
102
|
+
/>
|
|
103
|
+
</div>
|
|
104
|
+
<div className={cls.fieldRow}>
|
|
105
|
+
<label htmlFor="signin-password" className={cls.label}>Password</label>
|
|
106
|
+
<input
|
|
107
|
+
id="signin-password"
|
|
108
|
+
type="password"
|
|
109
|
+
value={password}
|
|
110
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
111
|
+
autoComplete="current-password"
|
|
112
|
+
required
|
|
113
|
+
className={cls.input}
|
|
114
|
+
disabled={submitting}
|
|
115
|
+
/>
|
|
116
|
+
</div>
|
|
117
|
+
{extraFields}
|
|
118
|
+
{error && <p className={cls.errorText}>{error}</p>}
|
|
119
|
+
<button type="submit" disabled={submitting} className={cls.submitButton}>
|
|
120
|
+
{submitting ? submittingLabel : submitLabel}
|
|
121
|
+
</button>
|
|
122
|
+
{belowSubmit}
|
|
123
|
+
</form>
|
|
124
|
+
)
|
|
125
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SignupForm — shared headless signup with state, validation, submit.
|
|
5
|
+
*
|
|
6
|
+
* Renders email + (optional) name + password fields. Apps wrap it in their
|
|
7
|
+
* own auth-shell / header / footer / OAuth buttons; the form just owns the
|
|
8
|
+
* field state and the submit handler.
|
|
9
|
+
*
|
|
10
|
+
* Future password-policy changes — strength meter, drop a field, add a field,
|
|
11
|
+
* swap to passkey — happen HERE, not in every app. startsim-j29.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { useState } from 'react'
|
|
15
|
+
import { useAuth } from '../client/use-auth'
|
|
16
|
+
|
|
17
|
+
export interface SignupFormProps {
|
|
18
|
+
/** Where to send the user after a successful signup. Apps call their own
|
|
19
|
+
* router; the form just signals success. */
|
|
20
|
+
onSuccess?: () => void
|
|
21
|
+
/** Optional override for the auth flow. By default the form calls
|
|
22
|
+
* useAuth().register — set this for screens that need to post elsewhere
|
|
23
|
+
* (e.g. invite-accept flows). */
|
|
24
|
+
onSubmit?: (payload: SignupPayload) => Promise<void>
|
|
25
|
+
/** Whether to collect a name field (default true). */
|
|
26
|
+
showName?: boolean
|
|
27
|
+
/** Optional extra content above the submit button (e.g. terms checkbox). */
|
|
28
|
+
extraFields?: React.ReactNode
|
|
29
|
+
/** Optional content rendered below the submit button (e.g. OAuth providers).
|
|
30
|
+
* Mirrors SignInForm.belowSubmit so call sites can render an "or continue
|
|
31
|
+
* with" provider strip without forking the form. */
|
|
32
|
+
belowSubmit?: React.ReactNode
|
|
33
|
+
/** Submit button label (default 'Sign up'). */
|
|
34
|
+
submitLabel?: string
|
|
35
|
+
/** Submitting label (default 'Creating account…'). */
|
|
36
|
+
submittingLabel?: string
|
|
37
|
+
/** Minimum password length (default 8). */
|
|
38
|
+
minPasswordLength?: number
|
|
39
|
+
/** Per-element className overrides. Apps style via these. Each slot has a
|
|
40
|
+
* sensible default class that matches a generic Tailwind look. */
|
|
41
|
+
classNames?: SignupFormClassNames
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface SignupPayload {
|
|
45
|
+
email: string
|
|
46
|
+
password: string
|
|
47
|
+
name?: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface SignupFormClassNames {
|
|
51
|
+
form?: string
|
|
52
|
+
fieldRow?: string
|
|
53
|
+
label?: string
|
|
54
|
+
input?: string
|
|
55
|
+
errorText?: string
|
|
56
|
+
submitButton?: string
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const DEFAULTS: Required<SignupFormClassNames> = {
|
|
60
|
+
form: 'space-y-4',
|
|
61
|
+
fieldRow: '',
|
|
62
|
+
label: 'block text-sm font-medium text-gray-700 mb-1',
|
|
63
|
+
input:
|
|
64
|
+
'w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-primary-500 focus:ring-1 focus:ring-primary-500',
|
|
65
|
+
errorText: 'text-sm text-red-600',
|
|
66
|
+
submitButton:
|
|
67
|
+
'w-full rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-50',
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function SignupForm({
|
|
71
|
+
onSuccess,
|
|
72
|
+
onSubmit,
|
|
73
|
+
showName = true,
|
|
74
|
+
extraFields,
|
|
75
|
+
belowSubmit,
|
|
76
|
+
submitLabel = 'Sign up',
|
|
77
|
+
submittingLabel = 'Creating account…',
|
|
78
|
+
minPasswordLength = 8,
|
|
79
|
+
classNames,
|
|
80
|
+
}: SignupFormProps) {
|
|
81
|
+
const auth = useAuth()
|
|
82
|
+
const [name, setName] = useState('')
|
|
83
|
+
const [email, setEmail] = useState('')
|
|
84
|
+
const [password, setPassword] = useState('')
|
|
85
|
+
const [error, setError] = useState('')
|
|
86
|
+
const [submitting, setSubmitting] = useState(false)
|
|
87
|
+
const cls = { ...DEFAULTS, ...(classNames ?? {}) }
|
|
88
|
+
|
|
89
|
+
async function handleSubmit(e: React.FormEvent) {
|
|
90
|
+
e.preventDefault()
|
|
91
|
+
setError('')
|
|
92
|
+
if (password.length < minPasswordLength) {
|
|
93
|
+
setError(`Password must be at least ${minPasswordLength} characters`)
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
setSubmitting(true)
|
|
97
|
+
try {
|
|
98
|
+
const payload: SignupPayload = { email, password }
|
|
99
|
+
if (showName && name.trim()) payload.name = name.trim()
|
|
100
|
+
if (onSubmit) await onSubmit(payload)
|
|
101
|
+
else await auth.register(payload)
|
|
102
|
+
onSuccess?.()
|
|
103
|
+
} catch (err) {
|
|
104
|
+
setError(err instanceof Error ? err.message : 'Could not create account')
|
|
105
|
+
} finally {
|
|
106
|
+
setSubmitting(false)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<form onSubmit={handleSubmit} className={cls.form}>
|
|
112
|
+
{showName && (
|
|
113
|
+
<div className={cls.fieldRow}>
|
|
114
|
+
<label htmlFor="signup-name" className={cls.label}>Name</label>
|
|
115
|
+
<input
|
|
116
|
+
id="signup-name"
|
|
117
|
+
type="text"
|
|
118
|
+
value={name}
|
|
119
|
+
onChange={(e) => setName(e.target.value)}
|
|
120
|
+
autoComplete="name"
|
|
121
|
+
className={cls.input}
|
|
122
|
+
disabled={submitting}
|
|
123
|
+
/>
|
|
124
|
+
</div>
|
|
125
|
+
)}
|
|
126
|
+
<div className={cls.fieldRow}>
|
|
127
|
+
<label htmlFor="signup-email" className={cls.label}>Email</label>
|
|
128
|
+
<input
|
|
129
|
+
id="signup-email"
|
|
130
|
+
type="email"
|
|
131
|
+
value={email}
|
|
132
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
133
|
+
autoComplete="email"
|
|
134
|
+
required
|
|
135
|
+
className={cls.input}
|
|
136
|
+
disabled={submitting}
|
|
137
|
+
/>
|
|
138
|
+
</div>
|
|
139
|
+
<div className={cls.fieldRow}>
|
|
140
|
+
<label htmlFor="signup-password" className={cls.label}>Password</label>
|
|
141
|
+
<input
|
|
142
|
+
id="signup-password"
|
|
143
|
+
type="password"
|
|
144
|
+
value={password}
|
|
145
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
146
|
+
autoComplete="new-password"
|
|
147
|
+
required
|
|
148
|
+
minLength={minPasswordLength}
|
|
149
|
+
className={cls.input}
|
|
150
|
+
disabled={submitting}
|
|
151
|
+
/>
|
|
152
|
+
</div>
|
|
153
|
+
{extraFields}
|
|
154
|
+
{error && <p className={cls.errorText}>{error}</p>}
|
|
155
|
+
<button type="submit" disabled={submitting} className={cls.submitButton}>
|
|
156
|
+
{submitting ? submittingLabel : submitLabel}
|
|
157
|
+
</button>
|
|
158
|
+
{belowSubmit}
|
|
159
|
+
</form>
|
|
160
|
+
)
|
|
161
|
+
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import { useEffect, useRef, useState } from 'react'
|
|
4
|
-
import { completeGoogleOAuth, getMe } from '../client/functions'
|
|
4
|
+
import { completeGoogleOAuth, completeMicrosoftOAuth, getMe } from '../client/functions'
|
|
5
5
|
import type { AuthUser } from '../types'
|
|
6
6
|
|
|
7
|
+
export type OAuthProvider = 'google' | 'microsoft'
|
|
8
|
+
|
|
7
9
|
export interface OAuthCallbackResult {
|
|
8
10
|
/** Access token from successful auth */
|
|
9
11
|
access: string
|
|
@@ -16,6 +18,12 @@ export interface UseOAuthCallbackOptions {
|
|
|
16
18
|
onSuccess: (result: OAuthCallbackResult) => void
|
|
17
19
|
/** Called on error */
|
|
18
20
|
onError?: (message: string) => void
|
|
21
|
+
/**
|
|
22
|
+
* Which OAuth provider to complete the callback against. Defaults to
|
|
23
|
+
* 'google' for backwards compatibility with the original single-provider
|
|
24
|
+
* call sites.
|
|
25
|
+
*/
|
|
26
|
+
provider?: OAuthProvider
|
|
19
27
|
}
|
|
20
28
|
|
|
21
29
|
export interface UseOAuthCallbackReturn {
|
|
@@ -89,7 +97,11 @@ export function useOAuthCallback(
|
|
|
89
97
|
}
|
|
90
98
|
|
|
91
99
|
try {
|
|
92
|
-
const
|
|
100
|
+
const provider: OAuthProvider = options.provider ?? 'google'
|
|
101
|
+
const result =
|
|
102
|
+
provider === 'microsoft'
|
|
103
|
+
? await completeMicrosoftOAuth(code, state)
|
|
104
|
+
: await completeGoogleOAuth(code, state)
|
|
93
105
|
const access = result?.access
|
|
94
106
|
|
|
95
107
|
if (!access) {
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/** @vitest-environment jsdom */
|
|
2
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
3
|
+
import { renderHook, waitFor, act } from '@testing-library/react';
|
|
4
|
+
import { useDomainClaims, type DomainClaimRow } from '../use-domain-claims';
|
|
5
|
+
|
|
6
|
+
function claim(overrides: Partial<DomainClaimRow> = {}): DomainClaimRow {
|
|
7
|
+
return {
|
|
8
|
+
id: 'd1',
|
|
9
|
+
companyId: 'c1',
|
|
10
|
+
domain: 'acme.com',
|
|
11
|
+
verified: false,
|
|
12
|
+
createdAt: '2025-01-01',
|
|
13
|
+
...overrides,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function makeOpts(initial: DomainClaimRow[] = []) {
|
|
18
|
+
const fetchClaims = vi.fn().mockResolvedValue(initial);
|
|
19
|
+
return {
|
|
20
|
+
fetchClaims,
|
|
21
|
+
createClaim: vi.fn(),
|
|
22
|
+
verifyDns: vi.fn(),
|
|
23
|
+
initiateEmailVerification: vi.fn(),
|
|
24
|
+
submitEmailCode: vi.fn(),
|
|
25
|
+
revokeClaim: vi.fn(),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('useDomainClaims', () => {
|
|
30
|
+
beforeEach(() => vi.clearAllMocks());
|
|
31
|
+
|
|
32
|
+
it('fetches the initial claims on mount', async () => {
|
|
33
|
+
const opts = makeOpts([claim()]);
|
|
34
|
+
const { result } = renderHook(() => useDomainClaims(opts));
|
|
35
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
36
|
+
expect(result.current.claims).toHaveLength(1);
|
|
37
|
+
expect(opts.fetchClaims).toHaveBeenCalledTimes(1);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('create splices the new row to the top so the verification_token shows', async () => {
|
|
41
|
+
const opts = makeOpts([claim({ id: 'old' })]);
|
|
42
|
+
opts.createClaim.mockResolvedValue(
|
|
43
|
+
claim({ id: 'new', verificationToken: 'startsim-verify=xyz' }),
|
|
44
|
+
);
|
|
45
|
+
const { result } = renderHook(() => useDomainClaims(opts));
|
|
46
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
47
|
+
|
|
48
|
+
let returned: DomainClaimRow | undefined;
|
|
49
|
+
await act(async () => {
|
|
50
|
+
returned = await result.current.create({ companyId: 'c1', domain: 'acme.com' });
|
|
51
|
+
});
|
|
52
|
+
expect(returned?.verificationToken).toBe('startsim-verify=xyz');
|
|
53
|
+
expect(result.current.claims.map((c) => c.id)).toEqual(['new', 'old']);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('verifyDns replaces the row in place', async () => {
|
|
57
|
+
const opts = makeOpts([claim({ id: 'd1' })]);
|
|
58
|
+
opts.verifyDns.mockResolvedValue(claim({ id: 'd1', verified: true, verifiedAt: '2025-02' }));
|
|
59
|
+
const { result } = renderHook(() => useDomainClaims(opts));
|
|
60
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
61
|
+
|
|
62
|
+
await act(async () => {
|
|
63
|
+
await result.current.verifyDns('d1');
|
|
64
|
+
});
|
|
65
|
+
expect(result.current.claims[0].verified).toBe(true);
|
|
66
|
+
expect(opts.verifyDns).toHaveBeenCalledWith('d1');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('verifyEmail submits the code + replaces the row', async () => {
|
|
70
|
+
const opts = makeOpts([claim({ id: 'd1' })]);
|
|
71
|
+
opts.submitEmailCode.mockResolvedValue(claim({ id: 'd1', verified: true }));
|
|
72
|
+
const { result } = renderHook(() => useDomainClaims(opts));
|
|
73
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
74
|
+
|
|
75
|
+
await act(async () => {
|
|
76
|
+
await result.current.verifyEmail('d1', '123456');
|
|
77
|
+
});
|
|
78
|
+
expect(opts.submitEmailCode).toHaveBeenCalledWith('d1', '123456');
|
|
79
|
+
expect(result.current.claims[0].verified).toBe(true);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('revoke is optimistic and reverts on failure', async () => {
|
|
83
|
+
const opts = makeOpts([claim({ id: 'd1' }), claim({ id: 'd2', domain: 'b.com' })]);
|
|
84
|
+
opts.revokeClaim.mockRejectedValue(new Error('nope'));
|
|
85
|
+
const { result } = renderHook(() => useDomainClaims(opts));
|
|
86
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
87
|
+
|
|
88
|
+
await expect(
|
|
89
|
+
act(async () => {
|
|
90
|
+
await result.current.revoke('d1');
|
|
91
|
+
}),
|
|
92
|
+
).rejects.toThrow('nope');
|
|
93
|
+
expect(result.current.claims.map((c) => c.id)).toEqual(['d1', 'd2']);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/** @vitest-environment jsdom */
|
|
2
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
3
|
+
import { renderHook, waitFor, act } from '@testing-library/react';
|
|
4
|
+
import { useInvitations, type InvitationRow } from '../use-invitations';
|
|
5
|
+
|
|
6
|
+
function row(overrides: Partial<InvitationRow> = {}): InvitationRow {
|
|
7
|
+
return {
|
|
8
|
+
id: 'i1',
|
|
9
|
+
email: 'a@x.com',
|
|
10
|
+
teamId: 't1',
|
|
11
|
+
role: 'member',
|
|
12
|
+
expiresAt: '2099-01-01',
|
|
13
|
+
isExpired: false,
|
|
14
|
+
isAccepted: false,
|
|
15
|
+
createdAt: '2025-01-01',
|
|
16
|
+
...overrides,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('useInvitations', () => {
|
|
21
|
+
beforeEach(() => vi.clearAllMocks());
|
|
22
|
+
|
|
23
|
+
it('only includes actionable invitations in pending', async () => {
|
|
24
|
+
const fetchInvitations = vi.fn().mockResolvedValue([
|
|
25
|
+
row({ id: 'i1' }),
|
|
26
|
+
row({ id: 'i2', acceptedAt: '2025-01-02', isAccepted: true }),
|
|
27
|
+
row({ id: 'i3', revokedAt: '2025-01-02' }),
|
|
28
|
+
row({ id: 'i4', isExpired: true }),
|
|
29
|
+
]);
|
|
30
|
+
const revokeInvitation = vi.fn();
|
|
31
|
+
const bulkInviteToTeam = vi.fn();
|
|
32
|
+
|
|
33
|
+
const { result } = renderHook(() =>
|
|
34
|
+
useInvitations({ fetchInvitations, revokeInvitation, bulkInviteToTeam }),
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
38
|
+
expect(result.current.pending.map((r) => r.id)).toEqual(['i1']);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('revoke is optimistic and reverts on failure', async () => {
|
|
42
|
+
const fetchInvitations = vi
|
|
43
|
+
.fn()
|
|
44
|
+
.mockResolvedValue([row({ id: 'i1' }), row({ id: 'i2', email: 'b@x.com' })]);
|
|
45
|
+
const revokeInvitation = vi.fn().mockRejectedValue(new Error('nope'));
|
|
46
|
+
const bulkInviteToTeam = vi.fn();
|
|
47
|
+
|
|
48
|
+
const { result } = renderHook(() =>
|
|
49
|
+
useInvitations({ fetchInvitations, revokeInvitation, bulkInviteToTeam }),
|
|
50
|
+
);
|
|
51
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
52
|
+
expect(result.current.pending).toHaveLength(2);
|
|
53
|
+
|
|
54
|
+
await expect(
|
|
55
|
+
act(async () => {
|
|
56
|
+
await result.current.revoke('i1');
|
|
57
|
+
}),
|
|
58
|
+
).rejects.toThrow('nope');
|
|
59
|
+
// After revert both rows are still pending.
|
|
60
|
+
expect(result.current.pending.map((r) => r.id)).toEqual(['i1', 'i2']);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('bulkInvite re-fetches after success', async () => {
|
|
64
|
+
const initial = [row({ id: 'i1' })];
|
|
65
|
+
const afterInvite = [...initial, row({ id: 'i2', email: 'b@x.com' })];
|
|
66
|
+
const fetchInvitations = vi
|
|
67
|
+
.fn()
|
|
68
|
+
.mockResolvedValueOnce(initial)
|
|
69
|
+
.mockResolvedValueOnce(afterInvite);
|
|
70
|
+
const revokeInvitation = vi.fn();
|
|
71
|
+
const bulkInviteToTeam = vi.fn().mockResolvedValue({ invited: [], skipped: [] });
|
|
72
|
+
|
|
73
|
+
const { result } = renderHook(() =>
|
|
74
|
+
useInvitations({ fetchInvitations, revokeInvitation, bulkInviteToTeam }),
|
|
75
|
+
);
|
|
76
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
77
|
+
|
|
78
|
+
await act(async () => {
|
|
79
|
+
await result.current.bulkInvite({
|
|
80
|
+
teamId: 't1',
|
|
81
|
+
invitations: [{ email: 'b@x.com', role: 'member' }],
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
expect(bulkInviteToTeam).toHaveBeenCalledWith('t1', [
|
|
85
|
+
{ email: 'b@x.com', role: 'member' },
|
|
86
|
+
]);
|
|
87
|
+
expect(fetchInvitations).toHaveBeenCalledTimes(2);
|
|
88
|
+
expect(result.current.pending.map((r) => r.id)).toEqual(['i1', 'i2']);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** @vitest-environment jsdom */
|
|
2
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
3
|
+
import { renderHook, waitFor } from '@testing-library/react';
|
|
4
|
+
import { useMembershipFromApi } from '../use-membership-from-api';
|
|
5
|
+
|
|
6
|
+
vi.mock('../../client/use-auth', () => ({
|
|
7
|
+
useAuth: () => ({
|
|
8
|
+
user: { id: 'u1', email: 'a@x.com', currentCompanyId: 'c1' },
|
|
9
|
+
isLoading: false,
|
|
10
|
+
isAuthenticated: true,
|
|
11
|
+
}),
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
describe('useMembershipFromApi', () => {
|
|
15
|
+
it('wires myTeams + retrieve + companies.retrieve through to useMembership', async () => {
|
|
16
|
+
const api = {
|
|
17
|
+
teams: {
|
|
18
|
+
myTeams: vi.fn().mockResolvedValue([
|
|
19
|
+
{
|
|
20
|
+
id: 'm1',
|
|
21
|
+
userId: 'u1',
|
|
22
|
+
teamId: 't1',
|
|
23
|
+
role: 'owner',
|
|
24
|
+
joinedAt: '2025-01-01',
|
|
25
|
+
team: { id: 't1', slug: 'core', name: 'Core', companyId: 'c1' },
|
|
26
|
+
},
|
|
27
|
+
]),
|
|
28
|
+
retrieve: vi.fn(),
|
|
29
|
+
},
|
|
30
|
+
companies: {
|
|
31
|
+
retrieve: vi.fn().mockResolvedValue({ id: 'c1', slug: 'acme', name: 'Acme' }),
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const { result } = renderHook(() => useMembershipFromApi(api));
|
|
36
|
+
|
|
37
|
+
await waitFor(() => {
|
|
38
|
+
expect(result.current.company?.name).toBe('Acme');
|
|
39
|
+
expect(result.current.currentTeam?.slug).toBe('core');
|
|
40
|
+
expect(result.current.isOwner).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
expect(api.teams.myTeams).toHaveBeenCalledTimes(1);
|
|
43
|
+
expect(api.companies.retrieve).toHaveBeenCalledWith('c1');
|
|
44
|
+
});
|
|
45
|
+
});
|