@startsimpli/auth 0.4.15 → 0.4.17
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/README.md +191 -377
- package/package.json +25 -12
- package/src/__tests__/auth-backend-contract.test.ts +84 -0
- package/src/__tests__/auth-client-oauth-register.test.ts +5 -8
- package/src/__tests__/auth-functions.test.ts +0 -1
- package/src/__tests__/session-user-groups.test.ts +45 -0
- package/src/__tests__/useauth-shape-contract.test.ts +0 -1
- package/src/client/__tests__/mock-backend.test.ts +141 -0
- package/src/client/__tests__/secure-session-storage.test.ts +75 -0
- package/src/client/__tests__/secure-token-storage.test.ts +69 -0
- package/src/client/__tests__/session-storage.test.ts +118 -0
- package/src/client/__tests__/token-auth-core.test.ts +190 -0
- package/src/client/auth-client.ts +71 -11
- package/src/client/auth-context.tsx +94 -17
- package/src/client/backend.ts +67 -0
- package/src/client/functions.ts +38 -57
- package/src/client/index.ts +15 -0
- package/src/client/mock-backend.ts +255 -0
- package/src/client/optional-secure-store.ts +21 -0
- package/src/client/secure-session-storage.native.ts +53 -0
- package/src/client/secure-session-storage.ts +20 -0
- package/src/client/secure-token-storage.native.ts +55 -0
- package/src/client/secure-token-storage.ts +32 -0
- package/src/client/session-storage.ts +142 -0
- package/src/client/token-auth-core.ts +190 -0
- package/src/client/token.ts +18 -0
- 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.test.tsx +136 -0
- package/src/hooks/index.ts +34 -0
- package/src/hooks/use-domain-claims.ts +144 -0
- package/src/hooks/use-invitations.ts +138 -0
- package/src/hooks/use-membership.ts +192 -0
- package/src/index.ts +43 -1
- package/src/server/index.ts +4 -0
- package/src/types/index.ts +5 -1
- package/src/utils/api-error.ts +54 -0
- 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,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,136 @@
|
|
|
1
|
+
/** @vitest-environment jsdom */
|
|
2
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
3
|
+
import { renderHook, waitFor, act } from '@testing-library/react';
|
|
4
|
+
import { useMembership } from '../use-membership';
|
|
5
|
+
|
|
6
|
+
// useMembership pulls auth from useAuth; mock the module so it sees a
|
|
7
|
+
// stable authenticated user without spinning up the real AuthProvider.
|
|
8
|
+
vi.mock('../../client/use-auth', () => ({
|
|
9
|
+
useAuth: () => ({
|
|
10
|
+
user: { id: 'u1', email: 'a@x.com', currentCompanyId: 'c1' },
|
|
11
|
+
session: null,
|
|
12
|
+
isLoading: false,
|
|
13
|
+
isAuthenticated: true,
|
|
14
|
+
login: vi.fn(),
|
|
15
|
+
logout: vi.fn(),
|
|
16
|
+
refreshUser: vi.fn(),
|
|
17
|
+
getAccessToken: vi.fn(),
|
|
18
|
+
register: vi.fn(),
|
|
19
|
+
signInWithGoogle: vi.fn(),
|
|
20
|
+
completeGoogleCallback: vi.fn(),
|
|
21
|
+
signInWithMicrosoft: vi.fn(),
|
|
22
|
+
completeMicrosoftCallback: vi.fn(),
|
|
23
|
+
hydrateSession: vi.fn(),
|
|
24
|
+
}),
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
describe('useMembership', () => {
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
vi.clearAllMocks();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('picks the OWNER membership when no currentCompanyId hint matches', async () => {
|
|
33
|
+
const fetchMyTeams = vi.fn().mockResolvedValue([
|
|
34
|
+
{
|
|
35
|
+
id: 'm1',
|
|
36
|
+
userId: 'u1',
|
|
37
|
+
teamId: 't1',
|
|
38
|
+
role: 'member',
|
|
39
|
+
joinedAt: '2025-01-01',
|
|
40
|
+
team: { id: 't1', slug: 'a', name: 'A', companyId: 'cX' },
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
id: 'm2',
|
|
44
|
+
userId: 'u1',
|
|
45
|
+
teamId: 't2',
|
|
46
|
+
role: 'owner',
|
|
47
|
+
joinedAt: '2025-01-02',
|
|
48
|
+
team: { id: 't2', slug: 'b', name: 'B', companyId: 'cY' },
|
|
49
|
+
},
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
const { result } = renderHook(() => useMembership({ fetchMyTeams }));
|
|
53
|
+
|
|
54
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
55
|
+
expect(result.current.role).toBe('owner');
|
|
56
|
+
expect(result.current.isOwner).toBe(true);
|
|
57
|
+
expect(result.current.isAdmin).toBe(true);
|
|
58
|
+
expect(result.current.canInvite).toBe(true);
|
|
59
|
+
expect(result.current.currentTeam?.id).toBe('t2');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('respects currentCompanyId on the AuthUser when picking', async () => {
|
|
63
|
+
const fetchMyTeams = vi.fn().mockResolvedValue([
|
|
64
|
+
{
|
|
65
|
+
id: 'm1',
|
|
66
|
+
userId: 'u1',
|
|
67
|
+
teamId: 't1',
|
|
68
|
+
role: 'admin',
|
|
69
|
+
joinedAt: '2025-01-01',
|
|
70
|
+
team: { id: 't1', slug: 'a', name: 'A', companyId: 'c1' },
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
id: 'm2',
|
|
74
|
+
userId: 'u1',
|
|
75
|
+
teamId: 't2',
|
|
76
|
+
role: 'owner',
|
|
77
|
+
joinedAt: '2025-01-02',
|
|
78
|
+
team: { id: 't2', slug: 'b', name: 'B', companyId: 'cZ' },
|
|
79
|
+
},
|
|
80
|
+
]);
|
|
81
|
+
|
|
82
|
+
const { result } = renderHook(() => useMembership({ fetchMyTeams }));
|
|
83
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
84
|
+
// currentCompanyId is 'c1' on the mocked user, so we land on team t1.
|
|
85
|
+
expect(result.current.currentTeam?.id).toBe('t1');
|
|
86
|
+
expect(result.current.role).toBe('admin');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('viewers cannot invite', async () => {
|
|
90
|
+
const fetchMyTeams = vi.fn().mockResolvedValue([
|
|
91
|
+
{
|
|
92
|
+
id: 'm1',
|
|
93
|
+
userId: 'u1',
|
|
94
|
+
teamId: 't1',
|
|
95
|
+
role: 'viewer',
|
|
96
|
+
joinedAt: '2025-01-01',
|
|
97
|
+
team: { id: 't1', slug: 'a', name: 'A', companyId: 'c1' },
|
|
98
|
+
},
|
|
99
|
+
]);
|
|
100
|
+
const { result } = renderHook(() => useMembership({ fetchMyTeams }));
|
|
101
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
102
|
+
expect(result.current.canInvite).toBe(false);
|
|
103
|
+
expect(result.current.isMemberOrAbove).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('refresh re-runs the loader', async () => {
|
|
107
|
+
const fetchMyTeams = vi
|
|
108
|
+
.fn()
|
|
109
|
+
.mockResolvedValueOnce([])
|
|
110
|
+
.mockResolvedValueOnce([
|
|
111
|
+
{
|
|
112
|
+
id: 'm1',
|
|
113
|
+
userId: 'u1',
|
|
114
|
+
teamId: 't1',
|
|
115
|
+
role: 'owner',
|
|
116
|
+
joinedAt: '2025-01-01',
|
|
117
|
+
team: { id: 't1', slug: 'a', name: 'A', companyId: 'c1' },
|
|
118
|
+
},
|
|
119
|
+
]);
|
|
120
|
+
const { result } = renderHook(() => useMembership({ fetchMyTeams }));
|
|
121
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
122
|
+
expect(result.current.memberships).toEqual([]);
|
|
123
|
+
await act(async () => {
|
|
124
|
+
await result.current.refresh();
|
|
125
|
+
});
|
|
126
|
+
expect(result.current.memberships).toHaveLength(1);
|
|
127
|
+
expect(result.current.role).toBe('owner');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('surfaces fetch errors via the error field', async () => {
|
|
131
|
+
const fetchMyTeams = vi.fn().mockRejectedValue(new Error('boom'));
|
|
132
|
+
const { result } = renderHook(() => useMembership({ fetchMyTeams }));
|
|
133
|
+
await waitFor(() => expect(result.current.error).not.toBeNull());
|
|
134
|
+
expect(result.current.error?.message).toBe('boom');
|
|
135
|
+
});
|
|
136
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Team-management hooks shipped with @startsimpli/auth (startsim-o7s).
|
|
3
|
+
*
|
|
4
|
+
* Each hook is *injection-friendly* — the caller passes API methods (typically
|
|
5
|
+
* from @startsimpli/api). That keeps the auth package free of api dependencies
|
|
6
|
+
* while still owning the shared state shape for /settings/team UIs.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export {
|
|
10
|
+
useMembership,
|
|
11
|
+
type UseMembershipOptions,
|
|
12
|
+
type UseMembershipReturn,
|
|
13
|
+
type MembershipCompany,
|
|
14
|
+
type MembershipTeam,
|
|
15
|
+
type MembershipRow,
|
|
16
|
+
type MembershipRole,
|
|
17
|
+
} from './use-membership';
|
|
18
|
+
|
|
19
|
+
export {
|
|
20
|
+
useInvitations,
|
|
21
|
+
type UseInvitationsOptions,
|
|
22
|
+
type UseInvitationsReturn,
|
|
23
|
+
type InvitationRow,
|
|
24
|
+
type BulkInviteEntry,
|
|
25
|
+
type BulkInviteResult,
|
|
26
|
+
} from './use-invitations';
|
|
27
|
+
|
|
28
|
+
export {
|
|
29
|
+
useDomainClaims,
|
|
30
|
+
type UseDomainClaimsOptions,
|
|
31
|
+
type UseDomainClaimsReturn,
|
|
32
|
+
type DomainClaimRow,
|
|
33
|
+
type DomainVerificationMethod,
|
|
34
|
+
} from './use-domain-claims';
|