@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,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
@@ -58,4 +58,31 @@ export type {
58
58
  SessionStorage,
59
59
  } from './client';
60
60
 
61
+ // Team-management hooks (startsim-o7s) — useMembership / useInvitations /
62
+ // useDomainClaims. Headless; caller passes the @startsimpli/api methods.
63
+ export {
64
+ useMembership,
65
+ useMembershipFromApi,
66
+ useInvitations,
67
+ useDomainClaims,
68
+ } from './hooks';
69
+ export type {
70
+ UseMembershipOptions,
71
+ UseMembershipReturn,
72
+ MembershipCompany,
73
+ MembershipTeam,
74
+ MembershipRow,
75
+ MembershipRole,
76
+ UseMembershipFromApiClient,
77
+ UseInvitationsOptions,
78
+ UseInvitationsReturn,
79
+ InvitationRow,
80
+ BulkInviteEntry,
81
+ BulkInviteResult,
82
+ UseDomainClaimsOptions,
83
+ UseDomainClaimsReturn,
84
+ DomainClaimRow,
85
+ DomainVerificationMethod,
86
+ } from './hooks';
87
+
61
88
  // DO NOT export './server' here - it uses next/headers and must be imported explicitly via '@startsimpli/auth/server'
@@ -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';
@@ -183,7 +183,6 @@ export interface PasswordResetRequest {
183
183
  export interface PasswordResetConfirm {
184
184
  token: string;
185
185
  password: string;
186
- passwordConfirm: string;
187
186
  }
188
187
 
189
188
  /**
@@ -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
+ }
@@ -1,3 +1,4 @@
1
1
  export * from './token';
2
2
  export * from './cookies';
3
+ export * from './central-auth';
3
4
  export * from '../validation';
@@ -54,18 +54,13 @@ export const passwordSchema = z
54
54
  );
55
55
 
56
56
  /**
57
- * Password confirmation schema
58
- * Validates password and confirmation match
57
+ * Password schema (alias retained for back-compat — passwordConfirm dropped
58
+ * from create-account/reset/change flows since browsers + password managers
59
+ * make the second field pure friction). startsim-nbq.
59
60
  */
60
- export const passwordConfirmSchema = z
61
- .object({
62
- password: passwordSchema,
63
- passwordConfirm: z.string(),
64
- })
65
- .refine((data) => data.password === data.passwordConfirm, {
66
- message: PasswordErrorCode.MISMATCH,
67
- path: ['passwordConfirm'],
68
- });
61
+ export const passwordConfirmSchema = z.object({
62
+ password: passwordSchema,
63
+ });
69
64
 
70
65
  /**
71
66
  * Password reset request schema
@@ -78,16 +73,10 @@ export const passwordResetRequestSchema = z.object({
78
73
  /**
79
74
  * Password reset confirm schema
80
75
  */
81
- export const passwordResetConfirmSchema = z
82
- .object({
83
- token: z.string().min(1, { message: 'Token is required' }),
84
- password: passwordSchema,
85
- passwordConfirm: z.string(),
86
- })
87
- .refine((data) => data.password === data.passwordConfirm, {
88
- message: PasswordErrorCode.MISMATCH,
89
- path: ['passwordConfirm'] as const,
90
- });
76
+ export const passwordResetConfirmSchema = z.object({
77
+ token: z.string().min(1, { message: 'Token is required' }),
78
+ password: passwordSchema,
79
+ });
91
80
 
92
81
  /**
93
82
  * Email verification request schema