@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.
Files changed (33) hide show
  1. package/package.json +17 -14
  2. package/src/__tests__/auth-client-oauth-register.test.ts +5 -8
  3. package/src/__tests__/auth-functions.test.ts +0 -1
  4. package/src/__tests__/useauth-shape-contract.test.ts +0 -1
  5. package/src/client/__tests__/mock-backend.test.ts +3 -6
  6. package/src/client/auth-client.ts +59 -2
  7. package/src/client/auth-context.tsx +62 -7
  8. package/src/client/backend.ts +10 -1
  9. package/src/client/functions.ts +31 -5
  10. package/src/client/mock-backend.ts +0 -3
  11. package/src/client/use-auth.ts +6 -1
  12. package/src/components/forgot-password-form.tsx +97 -0
  13. package/src/components/index.ts +5 -1
  14. package/src/components/oauth-callback.tsx +5 -2
  15. package/src/components/reset-password-form.tsx +124 -0
  16. package/src/components/sign-in-form.tsx +125 -0
  17. package/src/components/signup-form.tsx +161 -0
  18. package/src/components/use-oauth-callback.ts +14 -2
  19. package/src/hooks/__tests__/use-domain-claims.test.tsx +95 -0
  20. package/src/hooks/__tests__/use-invitations.test.tsx +90 -0
  21. package/src/hooks/__tests__/use-membership-from-api.test.tsx +45 -0
  22. package/src/hooks/__tests__/use-membership.test.tsx +136 -0
  23. package/src/hooks/index.ts +39 -0
  24. package/src/hooks/use-domain-claims.ts +144 -0
  25. package/src/hooks/use-invitations.ts +138 -0
  26. package/src/hooks/use-membership-from-api.ts +99 -0
  27. package/src/hooks/use-membership.ts +192 -0
  28. package/src/index.ts +27 -0
  29. package/src/server/index.ts +4 -0
  30. package/src/types/index.ts +0 -1
  31. package/src/utils/central-auth.ts +91 -0
  32. package/src/utils/index.ts +1 -0
  33. package/src/utils/validation.ts +10 -21
@@ -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,39 @@
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
+ useMembershipFromApi,
21
+ type UseMembershipFromApiClient,
22
+ } from './use-membership-from-api';
23
+
24
+ export {
25
+ useInvitations,
26
+ type UseInvitationsOptions,
27
+ type UseInvitationsReturn,
28
+ type InvitationRow,
29
+ type BulkInviteEntry,
30
+ type BulkInviteResult,
31
+ } from './use-invitations';
32
+
33
+ export {
34
+ useDomainClaims,
35
+ type UseDomainClaimsOptions,
36
+ type UseDomainClaimsReturn,
37
+ type DomainClaimRow,
38
+ type DomainVerificationMethod,
39
+ } from './use-domain-claims';
@@ -0,0 +1,144 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * useDomainClaims — keeps the list of EmailDomainClaim rows fresh + exposes
5
+ * the verify-DNS / verify-email / revoke / create mutations.
6
+ *
7
+ * Injection-friendly: pass api.domainClaims.* methods so @startsimpli/auth
8
+ * stays decoupled from @startsimpli/api. startsim-o7s.
9
+ */
10
+
11
+ import { useCallback, useEffect, useState } from 'react';
12
+
13
+ export type DomainVerificationMethod = 'dns_txt' | 'email_attestation';
14
+
15
+ /** Mirrors @startsimpli/api EmailDomainClaim but kept thin. */
16
+ export interface DomainClaimRow {
17
+ id: string;
18
+ companyId: string;
19
+ domain: string;
20
+ verified: boolean;
21
+ verificationMethod?: DomainVerificationMethod | null;
22
+ verificationToken?: string | null;
23
+ verifiedAt?: string | null;
24
+ createdAt: string;
25
+ }
26
+
27
+ export interface UseDomainClaimsOptions {
28
+ fetchClaims: () => Promise<DomainClaimRow[]>;
29
+ createClaim: (input: { companyId: string; domain: string }) => Promise<DomainClaimRow>;
30
+ verifyDns: (id: string) => Promise<DomainClaimRow>;
31
+ initiateEmailVerification: (id: string) => Promise<{ detail: string }>;
32
+ submitEmailCode: (id: string, code: string) => Promise<DomainClaimRow>;
33
+ revokeClaim: (id: string) => Promise<void>;
34
+ autoFetch?: boolean;
35
+ }
36
+
37
+ export interface UseDomainClaimsReturn {
38
+ claims: DomainClaimRow[];
39
+ isLoading: boolean;
40
+ error: Error | null;
41
+ refresh: () => Promise<void>;
42
+ create: (input: { companyId: string; domain: string }) => Promise<DomainClaimRow>;
43
+ verifyDns: (id: string) => Promise<DomainClaimRow>;
44
+ initiateEmail: (id: string) => Promise<{ detail: string }>;
45
+ verifyEmail: (id: string, code: string) => Promise<DomainClaimRow>;
46
+ revoke: (id: string) => Promise<void>;
47
+ }
48
+
49
+ export function useDomainClaims(options: UseDomainClaimsOptions): UseDomainClaimsReturn {
50
+ const {
51
+ fetchClaims,
52
+ createClaim,
53
+ verifyDns: apiVerifyDns,
54
+ initiateEmailVerification,
55
+ submitEmailCode,
56
+ revokeClaim,
57
+ autoFetch = true,
58
+ } = options;
59
+
60
+ const [claims, setClaims] = useState<DomainClaimRow[]>([]);
61
+ const [isLoading, setIsLoading] = useState<boolean>(autoFetch);
62
+ const [error, setError] = useState<Error | null>(null);
63
+
64
+ const refresh = useCallback(async () => {
65
+ setIsLoading(true);
66
+ setError(null);
67
+ try {
68
+ const fetched = await fetchClaims();
69
+ setClaims(fetched);
70
+ } catch (err) {
71
+ setError(err instanceof Error ? err : new Error(String(err)));
72
+ } finally {
73
+ setIsLoading(false);
74
+ }
75
+ }, [fetchClaims]);
76
+
77
+ useEffect(() => {
78
+ if (autoFetch) void refresh();
79
+ }, [autoFetch, refresh]);
80
+
81
+ const create = useCallback(
82
+ async (input: { companyId: string; domain: string }) => {
83
+ const row = await createClaim(input);
84
+ // Splice the new row in directly so the verification_token (creator-only)
85
+ // remains visible in the UI before any re-fetch hides it.
86
+ setClaims((prev) => [row, ...prev]);
87
+ return row;
88
+ },
89
+ [createClaim],
90
+ );
91
+
92
+ const replace = useCallback((id: string, next: DomainClaimRow) => {
93
+ setClaims((prev) => prev.map((c) => (c.id === id ? next : c)));
94
+ }, []);
95
+
96
+ const verifyDns = useCallback(
97
+ async (id: string) => {
98
+ const next = await apiVerifyDns(id);
99
+ replace(id, next);
100
+ return next;
101
+ },
102
+ [apiVerifyDns, replace],
103
+ );
104
+
105
+ const initiateEmail = useCallback(
106
+ async (id: string) => initiateEmailVerification(id),
107
+ [initiateEmailVerification],
108
+ );
109
+
110
+ const verifyEmail = useCallback(
111
+ async (id: string, code: string) => {
112
+ const next = await submitEmailCode(id, code);
113
+ replace(id, next);
114
+ return next;
115
+ },
116
+ [submitEmailCode, replace],
117
+ );
118
+
119
+ const revoke = useCallback(
120
+ async (id: string) => {
121
+ const snapshot = claims;
122
+ setClaims((prev) => prev.filter((c) => c.id !== id));
123
+ try {
124
+ await revokeClaim(id);
125
+ } catch (err) {
126
+ setClaims(snapshot);
127
+ throw err;
128
+ }
129
+ },
130
+ [claims, revokeClaim],
131
+ );
132
+
133
+ return {
134
+ claims,
135
+ isLoading,
136
+ error,
137
+ refresh,
138
+ create,
139
+ verifyDns,
140
+ initiateEmail,
141
+ verifyEmail,
142
+ revoke,
143
+ };
144
+ }
@@ -0,0 +1,138 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * useInvitations — keeps an in-memory view of the team invitations the
5
+ * current user can manage, plus mutation helpers for revoke + bulkInvite.
6
+ *
7
+ * Injection-friendly so the auth package stays decoupled from the api
8
+ * package; pass api.teamInvitations.list/revoke + api.teams.bulkInvite. startsim-o7s.
9
+ */
10
+
11
+ import { useCallback, useEffect, useState } from 'react';
12
+ import type { CompanyRole } from '../types';
13
+
14
+ /** Mirrors @startsimpli/api TeamInvitation but kept thin so the hook is decoupled. */
15
+ export interface InvitationRow {
16
+ id: string;
17
+ email: string;
18
+ teamId: string;
19
+ role: CompanyRole;
20
+ expiresAt: string;
21
+ acceptedAt?: string | null;
22
+ revokedAt?: string | null;
23
+ isExpired: boolean;
24
+ isAccepted: boolean;
25
+ createdAt: string;
26
+ }
27
+
28
+ export interface BulkInviteEntry {
29
+ email: string;
30
+ role: CompanyRole;
31
+ }
32
+
33
+ export interface BulkInviteResult {
34
+ invited: InvitationRow[];
35
+ skipped?: Array<{ email: string; reason: string }>;
36
+ }
37
+
38
+ export interface UseInvitationsOptions {
39
+ /**
40
+ * Required: list invitations (optionally scoped by teamId at call time
41
+ * via the rest of the caller's API surface).
42
+ */
43
+ fetchInvitations: () => Promise<InvitationRow[]>;
44
+ /** Revoke a single invitation. */
45
+ revokeInvitation: (id: string) => Promise<void>;
46
+ /** Bulk-invite to a team. */
47
+ bulkInviteToTeam: (
48
+ teamIdOrSlug: string,
49
+ invitations: BulkInviteEntry[],
50
+ ) => Promise<BulkInviteResult>;
51
+ /** Auto-fetch on mount? Defaults to true. */
52
+ autoFetch?: boolean;
53
+ }
54
+
55
+ export interface UseInvitationsReturn {
56
+ pending: InvitationRow[];
57
+ isLoading: boolean;
58
+ error: Error | null;
59
+ /** Force re-fetch. */
60
+ refresh: () => Promise<void>;
61
+ /** Revoke + optimistic prune from `pending`. */
62
+ revoke: (id: string) => Promise<void>;
63
+ /** Bulk-invite — re-fetches afterwards so `pending` includes the new rows. */
64
+ bulkInvite: (input: {
65
+ teamId: string;
66
+ invitations: BulkInviteEntry[];
67
+ }) => Promise<BulkInviteResult>;
68
+ }
69
+
70
+ export function useInvitations(options: UseInvitationsOptions): UseInvitationsReturn {
71
+ const {
72
+ fetchInvitations,
73
+ revokeInvitation,
74
+ bulkInviteToTeam,
75
+ autoFetch = true,
76
+ } = options;
77
+
78
+ const [rows, setRows] = useState<InvitationRow[]>([]);
79
+ const [isLoading, setIsLoading] = useState<boolean>(autoFetch);
80
+ const [error, setError] = useState<Error | null>(null);
81
+
82
+ const refresh = useCallback(async () => {
83
+ setIsLoading(true);
84
+ setError(null);
85
+ try {
86
+ const fetched = await fetchInvitations();
87
+ setRows(fetched);
88
+ } catch (err) {
89
+ setError(err instanceof Error ? err : new Error(String(err)));
90
+ } finally {
91
+ setIsLoading(false);
92
+ }
93
+ }, [fetchInvitations]);
94
+
95
+ useEffect(() => {
96
+ if (autoFetch) void refresh();
97
+ }, [autoFetch, refresh]);
98
+
99
+ const revoke = useCallback(
100
+ async (id: string) => {
101
+ // Optimistic prune; on failure, revert.
102
+ const snapshot = rows;
103
+ setRows((prev) => prev.filter((r) => r.id !== id));
104
+ try {
105
+ await revokeInvitation(id);
106
+ } catch (err) {
107
+ setRows(snapshot);
108
+ throw err;
109
+ }
110
+ },
111
+ [revokeInvitation, rows],
112
+ );
113
+
114
+ const bulkInvite = useCallback(
115
+ async ({ teamId, invitations }: { teamId: string; invitations: BulkInviteEntry[] }) => {
116
+ const result = await bulkInviteToTeam(teamId, invitations);
117
+ // Re-fetch so the new rows show in pending (plus their backend-assigned ids).
118
+ await refresh();
119
+ return result;
120
+ },
121
+ [bulkInviteToTeam, refresh],
122
+ );
123
+
124
+ // Only invitations that are still actionable (not accepted, not revoked) and
125
+ // not yet expired count as "pending" for UI banners.
126
+ const pending = rows.filter(
127
+ (r) => !r.acceptedAt && !r.revokedAt && !r.isExpired,
128
+ );
129
+
130
+ return {
131
+ pending,
132
+ isLoading,
133
+ error,
134
+ refresh,
135
+ revoke,
136
+ bulkInvite,
137
+ };
138
+ }
@@ -0,0 +1,99 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * useMembershipFromApi — thin adapter around `useMembership` that takes
5
+ * the @startsimpli/api `api` client and wires `fetchMyTeams`, `fetchTeam`,
6
+ * and `fetchCompany` automatically.
7
+ *
8
+ * Apps previously hand-rolled this adapter inside each /settings/team page;
9
+ * lifting it here means a one-liner adoption for the next consumer. The api
10
+ * argument is structurally typed (just the slice we use) so this hook can
11
+ * still live in @startsimpli/auth without depending on @startsimpli/api. startsim-o7s.
12
+ */
13
+
14
+ import { useMemo } from 'react';
15
+ import {
16
+ useMembership,
17
+ type MembershipRow,
18
+ type MembershipTeam,
19
+ type MembershipCompany,
20
+ type UseMembershipReturn,
21
+ } from './use-membership';
22
+
23
+ /** Minimum API surface used by the adapter. */
24
+ export interface UseMembershipFromApiClient {
25
+ teams: {
26
+ myTeams: () => Promise<
27
+ Array<{
28
+ id: string;
29
+ userId: string;
30
+ teamId: string;
31
+ role: MembershipRow['role'];
32
+ joinedAt: string;
33
+ team?: {
34
+ id: string;
35
+ slug: string;
36
+ name: string;
37
+ companyId: string;
38
+ };
39
+ }>
40
+ >;
41
+ retrieve: (idOrSlug: string) => Promise<{
42
+ id: string;
43
+ slug: string;
44
+ name: string;
45
+ companyId: string;
46
+ }>;
47
+ };
48
+ companies: {
49
+ retrieve: (idOrSlug: string) => Promise<{
50
+ id: string;
51
+ slug: string;
52
+ name: string;
53
+ }>;
54
+ };
55
+ }
56
+
57
+ export function useMembershipFromApi(
58
+ api: UseMembershipFromApiClient,
59
+ ): UseMembershipReturn {
60
+ const fetchMyTeams = useMemo(
61
+ () => async (): Promise<MembershipRow[]> => {
62
+ const rows = await api.teams.myTeams();
63
+ return rows.map((r) => ({
64
+ id: r.id,
65
+ userId: r.userId,
66
+ teamId: r.teamId,
67
+ role: r.role,
68
+ joinedAt: r.joinedAt,
69
+ team: r.team
70
+ ? {
71
+ id: r.team.id,
72
+ slug: r.team.slug,
73
+ name: r.team.name,
74
+ companyId: r.team.companyId,
75
+ }
76
+ : undefined,
77
+ }));
78
+ },
79
+ [api],
80
+ );
81
+
82
+ const fetchTeam = useMemo(
83
+ () => async (idOrSlug: string): Promise<MembershipTeam> => {
84
+ const t = await api.teams.retrieve(idOrSlug);
85
+ return { id: t.id, slug: t.slug, name: t.name, companyId: t.companyId };
86
+ },
87
+ [api],
88
+ );
89
+
90
+ const fetchCompany = useMemo(
91
+ () => async (idOrSlug: string): Promise<MembershipCompany> => {
92
+ const c = await api.companies.retrieve(idOrSlug);
93
+ return { id: c.id, slug: c.slug, name: c.name };
94
+ },
95
+ [api],
96
+ );
97
+
98
+ return useMembership({ fetchMyTeams, fetchTeam, fetchCompany });
99
+ }