@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,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,192 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* useMembership — derive the current user's company + team membership
|
|
5
|
+
* context off whatever the backend hands the @startsimpli/api client.
|
|
6
|
+
*
|
|
7
|
+
* The hook is *injection-friendly*: the caller passes in the API methods
|
|
8
|
+
* (`fetchMyTeams`, `fetchTeam`, `fetchCompany`) so @startsimpli/auth stays
|
|
9
|
+
* decoupled from @startsimpli/api. Most apps will pass
|
|
10
|
+
* api.teams.myTeams, api.teams.retrieve, api.companies.retrieve
|
|
11
|
+
* directly. startsim-o7s.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
15
|
+
import { useAuth } from '../client/use-auth';
|
|
16
|
+
import { hasRolePermission } from '../types';
|
|
17
|
+
import type { CompanyRole } from '../types';
|
|
18
|
+
|
|
19
|
+
export type MembershipRole = CompanyRole;
|
|
20
|
+
|
|
21
|
+
/** Minimal company-shape used by the hook (deduped from @startsimpli/api). */
|
|
22
|
+
export interface MembershipCompany {
|
|
23
|
+
id: string;
|
|
24
|
+
slug: string;
|
|
25
|
+
name: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Minimal team-shape used by the hook. */
|
|
29
|
+
export interface MembershipTeam {
|
|
30
|
+
id: string;
|
|
31
|
+
slug: string;
|
|
32
|
+
name: string;
|
|
33
|
+
companyId: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A row from `/team-members/my-teams/` — keeps just what the hook needs. */
|
|
37
|
+
export interface MembershipRow {
|
|
38
|
+
id: string;
|
|
39
|
+
userId: string;
|
|
40
|
+
teamId: string;
|
|
41
|
+
role: MembershipRole;
|
|
42
|
+
joinedAt: string;
|
|
43
|
+
team?: MembershipTeam;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface UseMembershipOptions {
|
|
47
|
+
/** Required: returns the current user's team memberships. */
|
|
48
|
+
fetchMyTeams: () => Promise<MembershipRow[]>;
|
|
49
|
+
/** Optional: hydrate the active team if `team` isn't embedded in the row. */
|
|
50
|
+
fetchTeam?: (idOrSlug: string) => Promise<MembershipTeam>;
|
|
51
|
+
/** Optional: hydrate the active company. */
|
|
52
|
+
fetchCompany?: (idOrSlug: string) => Promise<MembershipCompany>;
|
|
53
|
+
/**
|
|
54
|
+
* Optional override that picks which team is "current". Defaults to:
|
|
55
|
+
* 1. AuthUser.currentCompanyId → the membership whose team belongs there
|
|
56
|
+
* 2. otherwise, the first OWNER membership
|
|
57
|
+
* 3. otherwise, the first row
|
|
58
|
+
*/
|
|
59
|
+
pickCurrent?: (rows: MembershipRow[]) => MembershipRow | undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface UseMembershipReturn {
|
|
63
|
+
company: MembershipCompany | null;
|
|
64
|
+
currentTeam: MembershipTeam | null;
|
|
65
|
+
role: MembershipRole | null;
|
|
66
|
+
isOwner: boolean;
|
|
67
|
+
isAdmin: boolean;
|
|
68
|
+
isMemberOrAbove: boolean;
|
|
69
|
+
canInvite: boolean;
|
|
70
|
+
/** All membership rows for this user. */
|
|
71
|
+
memberships: MembershipRow[];
|
|
72
|
+
isLoading: boolean;
|
|
73
|
+
error: Error | null;
|
|
74
|
+
/** Force re-fetch. */
|
|
75
|
+
refresh: () => Promise<void>;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const EMPTY: UseMembershipReturn = {
|
|
79
|
+
company: null,
|
|
80
|
+
currentTeam: null,
|
|
81
|
+
role: null,
|
|
82
|
+
isOwner: false,
|
|
83
|
+
isAdmin: false,
|
|
84
|
+
isMemberOrAbove: false,
|
|
85
|
+
canInvite: false,
|
|
86
|
+
memberships: [],
|
|
87
|
+
isLoading: false,
|
|
88
|
+
error: null,
|
|
89
|
+
refresh: async () => {},
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export function useMembership(options: UseMembershipOptions): UseMembershipReturn {
|
|
93
|
+
const { fetchMyTeams, fetchTeam, fetchCompany, pickCurrent } = options;
|
|
94
|
+
const { user, isAuthenticated } = useAuth();
|
|
95
|
+
|
|
96
|
+
const [rows, setRows] = useState<MembershipRow[]>([]);
|
|
97
|
+
const [team, setTeam] = useState<MembershipTeam | null>(null);
|
|
98
|
+
const [company, setCompany] = useState<MembershipCompany | null>(null);
|
|
99
|
+
const [isLoading, setIsLoading] = useState<boolean>(isAuthenticated);
|
|
100
|
+
const [error, setError] = useState<Error | null>(null);
|
|
101
|
+
|
|
102
|
+
const currentCompanyHint = user?.currentCompanyId;
|
|
103
|
+
|
|
104
|
+
const refresh = useCallback(async () => {
|
|
105
|
+
if (!isAuthenticated) {
|
|
106
|
+
setRows([]);
|
|
107
|
+
setTeam(null);
|
|
108
|
+
setCompany(null);
|
|
109
|
+
setIsLoading(false);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
setIsLoading(true);
|
|
113
|
+
setError(null);
|
|
114
|
+
try {
|
|
115
|
+
const fetched = await fetchMyTeams();
|
|
116
|
+
setRows(fetched);
|
|
117
|
+
|
|
118
|
+
const picked =
|
|
119
|
+
pickCurrent?.(fetched) ??
|
|
120
|
+
defaultPickCurrent(fetched, currentCompanyHint);
|
|
121
|
+
|
|
122
|
+
if (!picked) {
|
|
123
|
+
setTeam(null);
|
|
124
|
+
setCompany(null);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const teamObj = picked.team ?? (fetchTeam ? await fetchTeam(picked.teamId) : null);
|
|
129
|
+
setTeam(teamObj);
|
|
130
|
+
|
|
131
|
+
if (teamObj && fetchCompany) {
|
|
132
|
+
const companyObj = await fetchCompany(teamObj.companyId);
|
|
133
|
+
setCompany(companyObj);
|
|
134
|
+
}
|
|
135
|
+
} catch (err) {
|
|
136
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
137
|
+
} finally {
|
|
138
|
+
setIsLoading(false);
|
|
139
|
+
}
|
|
140
|
+
}, [fetchMyTeams, fetchTeam, fetchCompany, pickCurrent, isAuthenticated, currentCompanyHint]);
|
|
141
|
+
|
|
142
|
+
useEffect(() => {
|
|
143
|
+
void refresh();
|
|
144
|
+
}, [refresh]);
|
|
145
|
+
|
|
146
|
+
return useMemo<UseMembershipReturn>(() => {
|
|
147
|
+
if (!isAuthenticated) return EMPTY;
|
|
148
|
+
const picked =
|
|
149
|
+
pickCurrent?.(rows) ?? defaultPickCurrent(rows, currentCompanyHint);
|
|
150
|
+
const role = picked?.role ?? null;
|
|
151
|
+
const isOwner = role === 'owner';
|
|
152
|
+
const isAdmin = !!role && hasRolePermission(role, 'admin');
|
|
153
|
+
const isMemberOrAbove = !!role && hasRolePermission(role, 'member');
|
|
154
|
+
return {
|
|
155
|
+
company,
|
|
156
|
+
currentTeam: team,
|
|
157
|
+
role,
|
|
158
|
+
isOwner,
|
|
159
|
+
isAdmin,
|
|
160
|
+
isMemberOrAbove,
|
|
161
|
+
// Owners + admins can invite; backend enforces same on bulk-invite.
|
|
162
|
+
canInvite: isAdmin,
|
|
163
|
+
memberships: rows,
|
|
164
|
+
isLoading,
|
|
165
|
+
error,
|
|
166
|
+
refresh,
|
|
167
|
+
};
|
|
168
|
+
}, [
|
|
169
|
+
isAuthenticated,
|
|
170
|
+
rows,
|
|
171
|
+
team,
|
|
172
|
+
company,
|
|
173
|
+
isLoading,
|
|
174
|
+
error,
|
|
175
|
+
refresh,
|
|
176
|
+
pickCurrent,
|
|
177
|
+
currentCompanyHint,
|
|
178
|
+
]);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function defaultPickCurrent(
|
|
182
|
+
rows: MembershipRow[],
|
|
183
|
+
currentCompanyId?: string,
|
|
184
|
+
): MembershipRow | undefined {
|
|
185
|
+
if (rows.length === 0) return undefined;
|
|
186
|
+
if (currentCompanyId) {
|
|
187
|
+
const hit = rows.find((r) => r.team?.companyId === currentCompanyId);
|
|
188
|
+
if (hit) return hit;
|
|
189
|
+
}
|
|
190
|
+
const owner = rows.find((r) => r.role === 'owner');
|
|
191
|
+
return owner ?? rows[0];
|
|
192
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -38,7 +38,49 @@ export {
|
|
|
38
38
|
authFetch,
|
|
39
39
|
hasPermission,
|
|
40
40
|
hasGroup,
|
|
41
|
+
createMockAuthBackend,
|
|
42
|
+
createMemorySessionStorage,
|
|
43
|
+
createWebSessionStorage,
|
|
44
|
+
createRememberAwareSessionStorage,
|
|
45
|
+
createSecureSessionStorage,
|
|
46
|
+
SESSION_STORAGE_KEY,
|
|
41
47
|
} from './client';
|
|
42
|
-
export type {
|
|
48
|
+
export type {
|
|
49
|
+
UseAuthReturn,
|
|
50
|
+
UseRequireAuthReturn,
|
|
51
|
+
UseRequireAuthOptions,
|
|
52
|
+
UsePermissionsReturn,
|
|
53
|
+
AuthBackend,
|
|
54
|
+
RegisterPayload,
|
|
55
|
+
MockAuthBackend,
|
|
56
|
+
MockAuthBackendOptions,
|
|
57
|
+
MockAccount,
|
|
58
|
+
SessionStorage,
|
|
59
|
+
} from './client';
|
|
60
|
+
|
|
61
|
+
// Team-management hooks (startsim-o7s) — useMembership / useInvitations /
|
|
62
|
+
// useDomainClaims. Headless; caller passes the @startsimpli/api methods.
|
|
63
|
+
export {
|
|
64
|
+
useMembership,
|
|
65
|
+
useInvitations,
|
|
66
|
+
useDomainClaims,
|
|
67
|
+
} from './hooks';
|
|
68
|
+
export type {
|
|
69
|
+
UseMembershipOptions,
|
|
70
|
+
UseMembershipReturn,
|
|
71
|
+
MembershipCompany,
|
|
72
|
+
MembershipTeam,
|
|
73
|
+
MembershipRow,
|
|
74
|
+
MembershipRole,
|
|
75
|
+
UseInvitationsOptions,
|
|
76
|
+
UseInvitationsReturn,
|
|
77
|
+
InvitationRow,
|
|
78
|
+
BulkInviteEntry,
|
|
79
|
+
BulkInviteResult,
|
|
80
|
+
UseDomainClaimsOptions,
|
|
81
|
+
UseDomainClaimsReturn,
|
|
82
|
+
DomainClaimRow,
|
|
83
|
+
DomainVerificationMethod,
|
|
84
|
+
} from './hooks';
|
|
43
85
|
|
|
44
86
|
// DO NOT export './server' here - it uses next/headers and must be imported explicitly via '@startsimpli/auth/server'
|
package/src/server/index.ts
CHANGED
|
@@ -18,3 +18,7 @@ export {
|
|
|
18
18
|
withRole,
|
|
19
19
|
type GuardResult,
|
|
20
20
|
} from './guards';
|
|
21
|
+
|
|
22
|
+
// Re-export token helpers so consumers (e.g. app middleware) don't have to
|
|
23
|
+
// reach into `./utils` directly.
|
|
24
|
+
export { isTokenExpired, decodeToken, getTokenExpiresAt } from '../utils/token';
|
package/src/types/index.ts
CHANGED
|
@@ -51,6 +51,11 @@ export interface AuthUser {
|
|
|
51
51
|
isStaff?: boolean;
|
|
52
52
|
isActive?: boolean;
|
|
53
53
|
name?: string | null;
|
|
54
|
+
// Role/permission membership. Mirrors the functional-API AuthUser so the
|
|
55
|
+
// session user works directly with hasGroup()/hasPermission(). Backends that
|
|
56
|
+
// model roles as string groups (incl. the mock backend) populate these.
|
|
57
|
+
groups?: string[];
|
|
58
|
+
permissions?: string[];
|
|
54
59
|
// Company/team context (if applicable)
|
|
55
60
|
companies?: Array<{
|
|
56
61
|
id: string;
|
|
@@ -178,7 +183,6 @@ export interface PasswordResetRequest {
|
|
|
178
183
|
export interface PasswordResetConfirm {
|
|
179
184
|
token: string;
|
|
180
185
|
password: string;
|
|
181
|
-
passwordConfirm: string;
|
|
182
186
|
}
|
|
183
187
|
|
|
184
188
|
/**
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extract a human-readable message from a Django REST Framework error response body.
|
|
3
|
+
*
|
|
4
|
+
* Handles the shapes we've seen in practice:
|
|
5
|
+
* { detail: "..." } → the string
|
|
6
|
+
* { detail: ["...", "..."] } → first string
|
|
7
|
+
* { detail: { token: ["Invalid..."] } } → first nested string
|
|
8
|
+
* { email: ["already exists"] } → first field-level string
|
|
9
|
+
* { non_field_errors: ["..."] } → first field-level string
|
|
10
|
+
* { error: "CODE", detail: { field: ["..."] } } → first nested string
|
|
11
|
+
*
|
|
12
|
+
* Pure and DOM-free, so it is shared by the web functional client (functions.ts)
|
|
13
|
+
* and the platform-neutral token-auth core (token-auth-core.ts).
|
|
14
|
+
*
|
|
15
|
+
* @internal Implementation detail of the auth package; do not rely on from outside.
|
|
16
|
+
*/
|
|
17
|
+
export function extractApiError(d: Record<string, unknown>, fallback: string): string {
|
|
18
|
+
const pluck = (val: unknown): string | null => {
|
|
19
|
+
if (typeof val === 'string') return val
|
|
20
|
+
if (Array.isArray(val) && val.length > 0) {
|
|
21
|
+
for (const item of val) {
|
|
22
|
+
const s = pluck(item)
|
|
23
|
+
if (s) return s
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
if (val && typeof val === 'object') {
|
|
27
|
+
for (const v of Object.values(val as Record<string, unknown>)) {
|
|
28
|
+
const s = pluck(v)
|
|
29
|
+
if (s) return s
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Standard DRF: { detail: "..." }
|
|
36
|
+
const fromDetail = pluck(d.detail)
|
|
37
|
+
if (fromDetail) return fromDetail
|
|
38
|
+
// Some backend shapes use `error` as the human-readable message (e.g.
|
|
39
|
+
// our Django auth errors: { "error": "No active account...", "code": "unauthorized" }).
|
|
40
|
+
// Prefer this over field-level probing so we don't accidentally return a
|
|
41
|
+
// code like "unauthorized" from a sibling field.
|
|
42
|
+
const fromError = pluck(d.error)
|
|
43
|
+
if (fromError) return fromError
|
|
44
|
+
// Field-level: { email: ["already exists"] } or { non_field_errors: ["..."] }
|
|
45
|
+
// Skip known meta/code fields so `{ code: "unauthorized" }` doesn't leak
|
|
46
|
+
// an internal identifier as a user-facing message.
|
|
47
|
+
const META_KEYS = new Set(['detail', 'error', 'code', 'statusCode', 'status', 'timestamp'])
|
|
48
|
+
for (const [key, val] of Object.entries(d)) {
|
|
49
|
+
if (META_KEYS.has(key)) continue
|
|
50
|
+
const s = pluck(val)
|
|
51
|
+
if (s) return s
|
|
52
|
+
}
|
|
53
|
+
return fallback
|
|
54
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers for redirecting to the central StartSimpli auth host
|
|
3
|
+
* (auth.startsimpli.com, see startsim-ul0).
|
|
4
|
+
*
|
|
5
|
+
* Per-app auth pages have been replaced by a single host that owns
|
|
6
|
+
* /signin, /signup, /forgot-password, /reset-password, /verify-email,
|
|
7
|
+
* /oauth/google/callback, /oauth/microsoft/callback, /completion, /error.
|
|
8
|
+
*
|
|
9
|
+
* Apps consume this helper to:
|
|
10
|
+
* 1. Build "Sign in" / "Create account" links with the `app` + `return_to`
|
|
11
|
+
* query params preserved.
|
|
12
|
+
* 2. Configure AuthProvider's `loginPath` so session-expired bounces hit the
|
|
13
|
+
* same host.
|
|
14
|
+
*
|
|
15
|
+
* Defaults to https://auth.startsimpli.com but can be overridden via the
|
|
16
|
+
* `NEXT_PUBLIC_AUTH_HOST` env var for staging / local dev.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export const DEFAULT_CENTRAL_AUTH_HOST = 'https://auth.startsimpli.com';
|
|
20
|
+
|
|
21
|
+
/** Flows owned by the central auth host. */
|
|
22
|
+
export type CentralAuthFlow =
|
|
23
|
+
| 'signin'
|
|
24
|
+
| 'signup'
|
|
25
|
+
| 'forgot-password'
|
|
26
|
+
| 'reset-password'
|
|
27
|
+
| 'verify-email'
|
|
28
|
+
| 'completion'
|
|
29
|
+
| 'error';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Resolve the central auth host. Reads `NEXT_PUBLIC_AUTH_HOST` when present
|
|
33
|
+
* so apps can point at a staging deploy without rebuilding the package.
|
|
34
|
+
*/
|
|
35
|
+
export function resolveCentralAuthHost(): string {
|
|
36
|
+
if (typeof process !== 'undefined' && process.env?.NEXT_PUBLIC_AUTH_HOST) {
|
|
37
|
+
return process.env.NEXT_PUBLIC_AUTH_HOST;
|
|
38
|
+
}
|
|
39
|
+
return DEFAULT_CENTRAL_AUTH_HOST;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface BuildCentralAuthUrlOptions {
|
|
43
|
+
/** Slug identifying the calling app (e.g. `vault`, `raise`, `market`). */
|
|
44
|
+
app: string;
|
|
45
|
+
/** Absolute URL the user should be returned to after the flow completes. */
|
|
46
|
+
returnTo?: string;
|
|
47
|
+
/** Override the host (otherwise resolved from env). */
|
|
48
|
+
host?: string;
|
|
49
|
+
/** Extra query params to tack on. */
|
|
50
|
+
extraParams?: Record<string, string>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Build a URL into the central auth host, preserving `?app=` and
|
|
55
|
+
* `?return_to=` consistently. Use this for hand-rolled links AND for
|
|
56
|
+
* AuthProvider's `loginPath` config.
|
|
57
|
+
*
|
|
58
|
+
* Example:
|
|
59
|
+
* buildCentralAuthUrl('signin', { app: 'vault', returnTo: 'https://vault.startsimpli.com/environments' })
|
|
60
|
+
* // => 'https://auth.startsimpli.com/signin?app=vault&return_to=https%3A%2F%2F...'
|
|
61
|
+
*/
|
|
62
|
+
export function buildCentralAuthUrl(
|
|
63
|
+
flow: CentralAuthFlow,
|
|
64
|
+
options: BuildCentralAuthUrlOptions,
|
|
65
|
+
): string {
|
|
66
|
+
const host = options.host ?? resolveCentralAuthHost();
|
|
67
|
+
const url = new URL(`/${flow}`, host);
|
|
68
|
+
url.searchParams.set('app', options.app);
|
|
69
|
+
if (options.returnTo) {
|
|
70
|
+
url.searchParams.set('return_to', options.returnTo);
|
|
71
|
+
}
|
|
72
|
+
if (options.extraParams) {
|
|
73
|
+
for (const [key, value] of Object.entries(options.extraParams)) {
|
|
74
|
+
url.searchParams.set(key, value);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return url.toString();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Convenience: redirect the browser to the central auth host's signin flow,
|
|
82
|
+
* preserving the current URL as `return_to`. Safe to call from client code.
|
|
83
|
+
*/
|
|
84
|
+
export function redirectToCentralSignin(app: string): void {
|
|
85
|
+
if (typeof window === 'undefined') return;
|
|
86
|
+
const url = buildCentralAuthUrl('signin', {
|
|
87
|
+
app,
|
|
88
|
+
returnTo: window.location.href,
|
|
89
|
+
});
|
|
90
|
+
window.location.href = url;
|
|
91
|
+
}
|
package/src/utils/index.ts
CHANGED