@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.
Files changed (48) hide show
  1. package/README.md +191 -377
  2. package/package.json +25 -12
  3. package/src/__tests__/auth-backend-contract.test.ts +84 -0
  4. package/src/__tests__/auth-client-oauth-register.test.ts +5 -8
  5. package/src/__tests__/auth-functions.test.ts +0 -1
  6. package/src/__tests__/session-user-groups.test.ts +45 -0
  7. package/src/__tests__/useauth-shape-contract.test.ts +0 -1
  8. package/src/client/__tests__/mock-backend.test.ts +141 -0
  9. package/src/client/__tests__/secure-session-storage.test.ts +75 -0
  10. package/src/client/__tests__/secure-token-storage.test.ts +69 -0
  11. package/src/client/__tests__/session-storage.test.ts +118 -0
  12. package/src/client/__tests__/token-auth-core.test.ts +190 -0
  13. package/src/client/auth-client.ts +71 -11
  14. package/src/client/auth-context.tsx +94 -17
  15. package/src/client/backend.ts +67 -0
  16. package/src/client/functions.ts +38 -57
  17. package/src/client/index.ts +15 -0
  18. package/src/client/mock-backend.ts +255 -0
  19. package/src/client/optional-secure-store.ts +21 -0
  20. package/src/client/secure-session-storage.native.ts +53 -0
  21. package/src/client/secure-session-storage.ts +20 -0
  22. package/src/client/secure-token-storage.native.ts +55 -0
  23. package/src/client/secure-token-storage.ts +32 -0
  24. package/src/client/session-storage.ts +142 -0
  25. package/src/client/token-auth-core.ts +190 -0
  26. package/src/client/token.ts +18 -0
  27. package/src/client/use-auth.ts +6 -1
  28. package/src/components/forgot-password-form.tsx +97 -0
  29. package/src/components/index.ts +5 -1
  30. package/src/components/oauth-callback.tsx +5 -2
  31. package/src/components/reset-password-form.tsx +124 -0
  32. package/src/components/sign-in-form.tsx +125 -0
  33. package/src/components/signup-form.tsx +161 -0
  34. package/src/components/use-oauth-callback.ts +14 -2
  35. package/src/hooks/__tests__/use-domain-claims.test.tsx +95 -0
  36. package/src/hooks/__tests__/use-invitations.test.tsx +90 -0
  37. package/src/hooks/__tests__/use-membership.test.tsx +136 -0
  38. package/src/hooks/index.ts +34 -0
  39. package/src/hooks/use-domain-claims.ts +144 -0
  40. package/src/hooks/use-invitations.ts +138 -0
  41. package/src/hooks/use-membership.ts +192 -0
  42. package/src/index.ts +43 -1
  43. package/src/server/index.ts +4 -0
  44. package/src/types/index.ts +5 -1
  45. package/src/utils/api-error.ts +54 -0
  46. package/src/utils/central-auth.ts +91 -0
  47. package/src/utils/index.ts +1 -0
  48. 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 result = await completeGoogleOAuth(code, state)
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';