@sentroy-co/client-sdk 2.13.7 → 2.13.9

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.
@@ -0,0 +1,101 @@
1
+ import { SentroyAuthError } from "./types"
2
+
3
+ /**
4
+ * Auth-as-a-Service shared HTTP layer. Project's signup/login/refresh
5
+ * endpoints'iyle aynı format (JSON request + JSON response, 401/403/4xx
6
+ * tek-tip `{error, error_description}` shape).
7
+ *
8
+ * `apiKey` opsiyonel — browser SDK end-user akışında apiKey-less
9
+ * (server-only güvenlik), admin SDK her zaman set'li.
10
+ */
11
+
12
+ const DEFAULT_AUTH_BASE_URL = "https://auth.sentroy.com"
13
+
14
+ export interface AuthHttpOptions {
15
+ authBaseUrl?: string
16
+ projectSlug: string
17
+ apiKey?: string
18
+ /** Hata-fırlatma yerine raw response döndür — caller fine-grained handling. */
19
+ rawErrors?: boolean
20
+ }
21
+
22
+ export class AuthHttp {
23
+ readonly baseUrl: string
24
+ readonly projectSlug: string
25
+ readonly apiKey?: string
26
+
27
+ constructor(opts: AuthHttpOptions) {
28
+ this.baseUrl = (opts.authBaseUrl || DEFAULT_AUTH_BASE_URL).replace(
29
+ /\/+$/,
30
+ "",
31
+ )
32
+ this.projectSlug = opts.projectSlug
33
+ this.apiKey = opts.apiKey
34
+ }
35
+
36
+ url(path: string): string {
37
+ const p = path.startsWith("/") ? path : `/${path}`
38
+ return `${this.baseUrl}/api/v1/auth/${this.projectSlug}${p}`
39
+ }
40
+
41
+ async request<T>(
42
+ path: string,
43
+ init: RequestInit & {
44
+ json?: unknown
45
+ bearer?: string
46
+ } = {},
47
+ ): Promise<T> {
48
+ const headers = new Headers(init.headers)
49
+ headers.set("Accept", "application/json")
50
+ if (init.json !== undefined) {
51
+ headers.set("Content-Type", "application/json")
52
+ }
53
+ // Auth precedence: explicit `bearer` (user access token) > project `apiKey`.
54
+ // Caller chooses the one that fits the endpoint.
55
+ if (init.bearer) {
56
+ headers.set("Authorization", `Bearer ${init.bearer}`)
57
+ } else if (this.apiKey) {
58
+ headers.set("Authorization", `Bearer ${this.apiKey}`)
59
+ }
60
+
61
+ const res = await fetch(this.url(path), {
62
+ ...init,
63
+ headers,
64
+ body: init.json !== undefined ? JSON.stringify(init.json) : init.body,
65
+ })
66
+
67
+ let payload: unknown = null
68
+ const ct = res.headers.get("content-type") ?? ""
69
+ if (ct.includes("application/json")) {
70
+ try {
71
+ payload = await res.json()
72
+ } catch {
73
+ payload = null
74
+ }
75
+ }
76
+
77
+ if (!res.ok) {
78
+ const err =
79
+ payload && typeof payload === "object"
80
+ ? (payload as { error?: string; error_description?: string })
81
+ : {}
82
+ throw new SentroyAuthError(
83
+ err.error ?? "http_error",
84
+ err.error_description ?? `HTTP ${res.status}`,
85
+ res.status,
86
+ )
87
+ }
88
+
89
+ // Sentroy admin endpoints wrap in `{data}`; public endpoints sometimes
90
+ // too. SDK auto-unwraps when present so callers don't keep .data on
91
+ // every call.
92
+ if (
93
+ payload &&
94
+ typeof payload === "object" &&
95
+ "data" in (payload as Record<string, unknown>)
96
+ ) {
97
+ return (payload as { data: T }).data
98
+ }
99
+ return payload as T
100
+ }
101
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Sentroy Auth-as-a-Service — browser/server SDK entry.
3
+ *
4
+ * import { SentroyAuth } from "@sentroy-co/client-sdk/auth"
5
+ * const auth = new SentroyAuth({ projectSlug: "my-app" })
6
+ *
7
+ * For server admin operations (verifyIdToken, etc):
8
+ * import { SentroyAuthAdmin } from "@sentroy-co/client-sdk/auth/admin"
9
+ *
10
+ * For React integration:
11
+ * import { SentroyAuthProvider, useAuth } from "@sentroy-co/client-sdk/auth/react"
12
+ */
13
+
14
+ export { SentroyAuth } from "./client"
15
+ export type {
16
+ SentroyAuthOptions,
17
+ AuthStateChangeListener,
18
+ AuthStorageAdapter,
19
+ } from "./client"
20
+ export {
21
+ SentroyAuthError,
22
+ type SentroyAuthUser,
23
+ type SignupResponse,
24
+ type LoginResponse,
25
+ type AuthTokensResponse,
26
+ type LoginOutcome,
27
+ type MfaChallengeResponse,
28
+ type SessionSummary,
29
+ type ActivityEntry,
30
+ type MfaStatus,
31
+ type MfaEnrollResponse,
32
+ type MfaVerifyEnrollmentResponse,
33
+ type PasskeySummary,
34
+ type SocialProvider,
35
+ } from "./types"
@@ -0,0 +1,351 @@
1
+ "use client"
2
+
3
+ import {
4
+ createContext,
5
+ useCallback,
6
+ useContext,
7
+ useEffect,
8
+ useMemo,
9
+ useState,
10
+ type ReactNode,
11
+ } from "react"
12
+ import { SentroyAuth, type SentroyAuthOptions } from "../client"
13
+ import type {
14
+ SentroyAuthUser,
15
+ SessionSummary,
16
+ ActivityEntry,
17
+ MfaStatus,
18
+ PasskeySummary,
19
+ } from "../types"
20
+
21
+ /**
22
+ * Sentroy Auth React integration.
23
+ *
24
+ * <SentroyAuthProvider projectSlug="my-app">
25
+ * <App />
26
+ * </SentroyAuthProvider>
27
+ *
28
+ * const { user, loading, signIn, signOut } = useAuth()
29
+ *
30
+ * Provider içeride tek bir `SentroyAuth` instance tutar (mount/unmount
31
+ * arasında stable), `onAuthStateChanged` ile React state'i senkron tutar.
32
+ * `loading` ilk render → restore tamam mı henüz değil ayrımı için.
33
+ *
34
+ * Yeni method'lar (MFA, magic link, passkey, social, /me/*) tüm
35
+ * `auth` instance üzerinden erişilebilir — context'te kısayol olarak
36
+ * en sık kullanılanlar mevcut.
37
+ */
38
+
39
+ interface AuthContextValue {
40
+ /** Underlying SDK instance — gelişmiş senaryolarda doğrudan kullan. */
41
+ auth: SentroyAuth
42
+ user: SentroyAuthUser | null
43
+ /** True iken provider ilk state'i restore etmiş değil — UI'da
44
+ * "spinner" göster, "redirect to /login" tetikleme. */
45
+ loading: boolean
46
+ // Sık kullanılan kısayollar (proxies)
47
+ signIn: SentroyAuth["signIn"]
48
+ signUp: SentroyAuth["signUp"]
49
+ signOut: SentroyAuth["signOut"]
50
+ sendPasswordReset: SentroyAuth["sendPasswordReset"]
51
+ verifyEmail: SentroyAuth["verifyEmail"]
52
+ verifyMfa: SentroyAuth["verifyMfa"]
53
+ sendMagicLink: SentroyAuth["sendMagicLink"]
54
+ consumeMagicLink: SentroyAuth["consumeMagicLink"]
55
+ acceptInvitation: SentroyAuth["acceptInvitation"]
56
+ socialAuthorizeUrl: SentroyAuth["socialAuthorizeUrl"]
57
+ consumeRedirectFragment: SentroyAuth["consumeRedirectFragment"]
58
+ }
59
+
60
+ const AuthContext = createContext<AuthContextValue | null>(null)
61
+
62
+ export function SentroyAuthProvider({
63
+ children,
64
+ autoConsumeFragment = true,
65
+ ...opts
66
+ }: SentroyAuthOptions & {
67
+ children: ReactNode
68
+ /** Mount'ta `window.location.hash`'ten social-login fragment'ı
69
+ * consume et — default true. RP'nin redirectUri'sinde session
70
+ * otomatik kurulur. */
71
+ autoConsumeFragment?: boolean
72
+ }) {
73
+ // Single instance — opts deep-compare'a girersek dependency drift'i
74
+ // restart'a yol açar. Caller `projectSlug` değiştirmemeli runtime'da.
75
+ const auth = useMemo(
76
+ () => new SentroyAuth(opts),
77
+ // eslint-disable-next-line react-hooks/exhaustive-deps
78
+ [opts.projectSlug, opts.authBaseUrl, opts.apiKey],
79
+ )
80
+ const [user, setUser] = useState<SentroyAuthUser | null>(auth.user)
81
+ const [loading, setLoading] = useState(true)
82
+
83
+ useEffect(() => {
84
+ const unsubscribe = auth.onAuthStateChanged((u) => {
85
+ setUser(u)
86
+ setLoading(false)
87
+ })
88
+ return unsubscribe
89
+ }, [auth])
90
+
91
+ // Social login redirect handler — fragment varsa otomatik consume
92
+ useEffect(() => {
93
+ if (!autoConsumeFragment) return
94
+ if (typeof window === "undefined") return
95
+ if (!window.location.hash.includes("access_token=")) return
96
+ auth.consumeRedirectFragment().catch(() => {
97
+ // Fragment varsa ama consume fail ise sessizce yut — caller
98
+ // gerekirse manuel tekrar dener.
99
+ })
100
+ }, [auth, autoConsumeFragment])
101
+
102
+ const value = useMemo<AuthContextValue>(
103
+ () => ({
104
+ auth,
105
+ user,
106
+ loading,
107
+ signIn: (i) => auth.signIn(i),
108
+ signUp: (i) => auth.signUp(i),
109
+ signOut: () => auth.signOut(),
110
+ sendPasswordReset: (e) => auth.sendPasswordReset(e),
111
+ verifyEmail: (t) => auth.verifyEmail(t),
112
+ verifyMfa: (i) => auth.verifyMfa(i),
113
+ sendMagicLink: (i) => auth.sendMagicLink(i),
114
+ consumeMagicLink: (t) => auth.consumeMagicLink(t),
115
+ acceptInvitation: (i) => auth.acceptInvitation(i),
116
+ socialAuthorizeUrl: (p, o) => auth.socialAuthorizeUrl(p, o),
117
+ consumeRedirectFragment: () => auth.consumeRedirectFragment(),
118
+ }),
119
+ [auth, user, loading],
120
+ )
121
+
122
+ return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
123
+ }
124
+
125
+ export function useAuth(): AuthContextValue {
126
+ const ctx = useContext(AuthContext)
127
+ if (!ctx) {
128
+ throw new Error(
129
+ "useAuth must be used inside <SentroyAuthProvider> — wrap your app root.",
130
+ )
131
+ }
132
+ return ctx
133
+ }
134
+
135
+ /**
136
+ * Convenience: yalnızca current user istenirse. `loading` durumunda null
137
+ * dönerken bekleyebilirsin.
138
+ */
139
+ export function useUser(): SentroyAuthUser | null {
140
+ return useAuth().user
141
+ }
142
+
143
+ /**
144
+ * Reactive sessions hook — `/me/sessions` çağırır, refresh trigger eden
145
+ * `refresh()` döner. Manual revoke sonrası caller `refresh()` çağırır.
146
+ */
147
+ export function useSessions(): {
148
+ sessions: SessionSummary[] | null
149
+ loading: boolean
150
+ error: Error | null
151
+ refresh: () => Promise<void>
152
+ revoke: (id: string) => Promise<void>
153
+ } {
154
+ const { auth, user } = useAuth()
155
+ const [sessions, setSessions] = useState<SessionSummary[] | null>(null)
156
+ const [loading, setLoading] = useState(false)
157
+ const [error, setError] = useState<Error | null>(null)
158
+
159
+ const refresh = useCallback(async () => {
160
+ if (!user) {
161
+ setSessions(null)
162
+ return
163
+ }
164
+ setLoading(true)
165
+ setError(null)
166
+ try {
167
+ const data = await auth.listSessions()
168
+ setSessions(data)
169
+ } catch (e) {
170
+ setError(e instanceof Error ? e : new Error(String(e)))
171
+ } finally {
172
+ setLoading(false)
173
+ }
174
+ }, [auth, user])
175
+
176
+ useEffect(() => {
177
+ refresh()
178
+ }, [refresh])
179
+
180
+ const revoke = useCallback(
181
+ async (id: string) => {
182
+ await auth.revokeSession(id)
183
+ await refresh()
184
+ },
185
+ [auth, refresh],
186
+ )
187
+
188
+ return { sessions, loading, error, refresh, revoke }
189
+ }
190
+
191
+ /**
192
+ * Reactive activity log hook — `/me/activity`. RP'nin "recent activity"
193
+ * tab'ı için.
194
+ */
195
+ export function useActivity(): {
196
+ activity: ActivityEntry[] | null
197
+ loading: boolean
198
+ error: Error | null
199
+ refresh: () => Promise<void>
200
+ } {
201
+ const { auth, user } = useAuth()
202
+ const [activity, setActivity] = useState<ActivityEntry[] | null>(null)
203
+ const [loading, setLoading] = useState(false)
204
+ const [error, setError] = useState<Error | null>(null)
205
+
206
+ const refresh = useCallback(async () => {
207
+ if (!user) {
208
+ setActivity(null)
209
+ return
210
+ }
211
+ setLoading(true)
212
+ setError(null)
213
+ try {
214
+ const data = await auth.getActivity()
215
+ setActivity(data)
216
+ } catch (e) {
217
+ setError(e instanceof Error ? e : new Error(String(e)))
218
+ } finally {
219
+ setLoading(false)
220
+ }
221
+ }, [auth, user])
222
+
223
+ useEffect(() => {
224
+ refresh()
225
+ }, [refresh])
226
+
227
+ return { activity, loading, error, refresh }
228
+ }
229
+
230
+ /**
231
+ * Reactive MFA status. enroll / verify / disable wrapper'ları, status
232
+ * otomatik yeniden çekilir.
233
+ */
234
+ export function useMfa(): {
235
+ status: MfaStatus | null
236
+ loading: boolean
237
+ error: Error | null
238
+ refresh: () => Promise<void>
239
+ enrollTotp: SentroyAuth["mfa"]["enrollTotp"]
240
+ verifyTotpEnrollment: (code: string) => Promise<void>
241
+ disableTotp: (currentPassword: string) => Promise<void>
242
+ } {
243
+ const { auth, user } = useAuth()
244
+ const [status, setStatus] = useState<MfaStatus | null>(null)
245
+ const [loading, setLoading] = useState(false)
246
+ const [error, setError] = useState<Error | null>(null)
247
+
248
+ const refresh = useCallback(async () => {
249
+ if (!user) {
250
+ setStatus(null)
251
+ return
252
+ }
253
+ setLoading(true)
254
+ setError(null)
255
+ try {
256
+ const data = await auth.mfa.getStatus()
257
+ setStatus(data)
258
+ } catch (e) {
259
+ setError(e instanceof Error ? e : new Error(String(e)))
260
+ } finally {
261
+ setLoading(false)
262
+ }
263
+ }, [auth, user])
264
+
265
+ useEffect(() => {
266
+ refresh()
267
+ }, [refresh])
268
+
269
+ const verifyTotpEnrollment = useCallback(
270
+ async (code: string) => {
271
+ await auth.mfa.verifyTotpEnrollment(code)
272
+ await refresh()
273
+ },
274
+ [auth, refresh],
275
+ )
276
+
277
+ const disableTotp = useCallback(
278
+ async (currentPassword: string) => {
279
+ await auth.mfa.disableTotp(currentPassword)
280
+ await refresh()
281
+ },
282
+ [auth, refresh],
283
+ )
284
+
285
+ return {
286
+ status,
287
+ loading,
288
+ error,
289
+ refresh,
290
+ enrollTotp: () => auth.mfa.enrollTotp(),
291
+ verifyTotpEnrollment,
292
+ disableTotp,
293
+ }
294
+ }
295
+
296
+ /**
297
+ * Reactive passkey list — list/delete/register, mutation sonrası
298
+ * otomatik refresh.
299
+ */
300
+ export function usePasskeys(): {
301
+ passkeys: PasskeySummary[] | null
302
+ loading: boolean
303
+ error: Error | null
304
+ refresh: () => Promise<void>
305
+ register: (deviceName?: string) => Promise<void>
306
+ remove: (id: string) => Promise<void>
307
+ } {
308
+ const { auth, user } = useAuth()
309
+ const [passkeys, setPasskeys] = useState<PasskeySummary[] | null>(null)
310
+ const [loading, setLoading] = useState(false)
311
+ const [error, setError] = useState<Error | null>(null)
312
+
313
+ const refresh = useCallback(async () => {
314
+ if (!user) {
315
+ setPasskeys(null)
316
+ return
317
+ }
318
+ setLoading(true)
319
+ setError(null)
320
+ try {
321
+ const data = await auth.passkey.list()
322
+ setPasskeys(data)
323
+ } catch (e) {
324
+ setError(e instanceof Error ? e : new Error(String(e)))
325
+ } finally {
326
+ setLoading(false)
327
+ }
328
+ }, [auth, user])
329
+
330
+ useEffect(() => {
331
+ refresh()
332
+ }, [refresh])
333
+
334
+ const register = useCallback(
335
+ async (deviceName?: string) => {
336
+ await auth.passkey.register(deviceName)
337
+ await refresh()
338
+ },
339
+ [auth, refresh],
340
+ )
341
+
342
+ const remove = useCallback(
343
+ async (id: string) => {
344
+ await auth.passkey.delete(id)
345
+ await refresh()
346
+ },
347
+ [auth, refresh],
348
+ )
349
+
350
+ return { passkeys, loading, error, refresh, register, remove }
351
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Sentroy Auth-as-a-Service — SDK types.
3
+ *
4
+ * Public types are kept narrow on purpose: SDK shapes evolve with backend;
5
+ * caller code should depend on these names, not on hand-coded interfaces.
6
+ */
7
+
8
+ export interface SentroyAuthUser {
9
+ id: string
10
+ authProjectId: string
11
+ email: string
12
+ emailVerified: boolean
13
+ displayName: string | null
14
+ image: string | null
15
+ metadata: Record<string, unknown>
16
+ lastLoginAt: string | null
17
+ createdAt: string
18
+ updatedAt: string
19
+ }
20
+
21
+ export interface AuthTokensResponse {
22
+ accessToken: string
23
+ refreshToken: string
24
+ expiresIn: number
25
+ tokenType: "Bearer"
26
+ }
27
+
28
+ export interface SignupResponse {
29
+ user: SentroyAuthUser
30
+ /** Email verification gerekiyorsa undefined; aksi halde set. */
31
+ accessToken?: string
32
+ refreshToken?: string
33
+ expiresIn?: number
34
+ tokenType?: "Bearer"
35
+ emailVerificationRequired?: boolean
36
+ }
37
+
38
+ export interface LoginResponse {
39
+ user: SentroyAuthUser
40
+ accessToken: string
41
+ refreshToken: string
42
+ expiresIn: number
43
+ tokenType: "Bearer"
44
+ }
45
+
46
+ export interface AuthApiError {
47
+ error: string
48
+ error_description: string
49
+ }
50
+
51
+ export class SentroyAuthError extends Error {
52
+ readonly code: string
53
+ readonly status: number
54
+ constructor(code: string, message: string, status: number) {
55
+ super(message)
56
+ this.name = "SentroyAuthError"
57
+ this.code = code
58
+ this.status = status
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Login response when MFA is enrolled — first step returns mfaRequired+
64
+ * mfaToken; second step `verifyMfa(mfaToken, code)` issues final tokens.
65
+ */
66
+ export interface MfaChallengeResponse {
67
+ mfaRequired: true
68
+ mfaToken: string
69
+ factorType: "totp"
70
+ }
71
+
72
+ /** Discriminated union — Login may resolve to tokens OR MFA challenge. */
73
+ export type LoginOutcome =
74
+ | { kind: "tokens"; data: LoginResponse }
75
+ | { kind: "mfa"; data: MfaChallengeResponse }
76
+
77
+ export interface SessionSummary {
78
+ id: string
79
+ refreshTokenPrefix: string
80
+ userAgent: string | null
81
+ ip: string | null
82
+ expiresAt: string
83
+ createdAt: string
84
+ }
85
+
86
+ export interface ActivityEntry {
87
+ id: string
88
+ action: string
89
+ ipAddress: string | null
90
+ createdAt: string
91
+ details: Record<string, unknown> | null
92
+ }
93
+
94
+ export interface MfaStatus {
95
+ enrolled: boolean
96
+ factorType?: "totp"
97
+ verifiedAt?: string | null
98
+ recoveryCodesRemaining?: number
99
+ }
100
+
101
+ export interface MfaEnrollResponse {
102
+ secret: string
103
+ otpauthUri: string
104
+ }
105
+
106
+ export interface MfaVerifyEnrollmentResponse {
107
+ enrolled: true
108
+ recoveryCodes: string[]
109
+ }
110
+
111
+ export interface PasskeySummary {
112
+ id: string
113
+ credentialIdPrefix: string
114
+ deviceName: string | null
115
+ transports: string[]
116
+ lastUsedAt: string | null
117
+ createdAt: string
118
+ }
119
+
120
+ export type SocialProvider =
121
+ | "google"
122
+ | "github"
123
+ | "facebook"
124
+ | "microsoft"
125
+ | "twitter"
126
+ | "apple"