@omg-dev/sdk 0.4.24
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/dist/OmgBadge-LAcimQp0.mjs +345 -0
- package/dist/VibesFeedback-BF2Vf6FK.mjs +808 -0
- package/dist/brand/auto.mjs +18 -0
- package/dist/feedback/auto.mjs +26 -0
- package/dist/index.mjs +1611 -0
- package/package.json +43 -0
- package/src/auth/auto-prompt.tsx +50 -0
- package/src/auth/bridge.ts +63 -0
- package/src/auth/client.ts +222 -0
- package/src/auth/fetch.ts +23 -0
- package/src/auth/guard.tsx +24 -0
- package/src/auth/index.ts +23 -0
- package/src/auth/login.tsx +267 -0
- package/src/auth/mail-apps.ts +52 -0
- package/src/auth/react.tsx +248 -0
- package/src/brand/OmgBadge.tsx +366 -0
- package/src/brand/auto.tsx +35 -0
- package/src/brand/index.ts +1 -0
- package/src/feedback/VibesFeedback.tsx +360 -0
- package/src/feedback/auto.tsx +47 -0
- package/src/feedback/gestures.ts +296 -0
- package/src/feedback/index.ts +18 -0
- package/src/feedback/screenshot.ts +37 -0
- package/src/feedback/trace.ts +166 -0
- package/src/index.ts +1042 -0
- package/src/notifications/index.tsx +179 -0
- package/src/sandbox.test.ts +61 -0
- package/src/sandbox.ts +106 -0
- package/src/storage/VibesUpload.tsx +140 -0
- package/src/storage/index.ts +12 -0
- package/src/storage/useUpload.ts +167 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
// @omg-dev/sdk/auth — Drop-in login UI: email code + passkey.
|
|
2
|
+
//
|
|
3
|
+
// Sign-in is a 6-digit emailed code (no magic-link redirect): the session is
|
|
4
|
+
// created in the same browser context that typed the code, so it works in
|
|
5
|
+
// installed PWAs (iOS standalone PWAs can't share Safari's cookies) and when
|
|
6
|
+
// the email is read on another device.
|
|
7
|
+
//
|
|
8
|
+
// Styled with shadcn's *semantic* Tailwind tokens (bg-card, bg-primary,
|
|
9
|
+
// text-foreground, border, etc.) which resolve to CSS variables that
|
|
10
|
+
// `shadcn init` writes into the app's globals.css. The component picks
|
|
11
|
+
// up whatever theme the host app uses — light, dark, branded — without
|
|
12
|
+
// importing any shadcn components (those live in apps/<x>/src/components/ui
|
|
13
|
+
// and aren't shipped via npm).
|
|
14
|
+
//
|
|
15
|
+
// Apps that want a fully custom layout can compose `useAuth()` directly.
|
|
16
|
+
|
|
17
|
+
import { useRef, useState, type FormEvent } from "react"
|
|
18
|
+
import { useAuth } from "./react"
|
|
19
|
+
import { mailAppForEmail } from "./mail-apps"
|
|
20
|
+
|
|
21
|
+
// Dependency-free spinner — inherits color via `currentColor`, sized by the caller.
|
|
22
|
+
function Spinner({ className = "" }: { className?: string }) {
|
|
23
|
+
return (
|
|
24
|
+
<svg
|
|
25
|
+
className={`animate-spin ${className}`}
|
|
26
|
+
viewBox="0 0 24 24"
|
|
27
|
+
fill="none"
|
|
28
|
+
aria-hidden="true"
|
|
29
|
+
>
|
|
30
|
+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
31
|
+
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 0 1 8-8v4a4 4 0 0 0-4 4H4z" />
|
|
32
|
+
</svg>
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const CODE_LENGTH = 6
|
|
37
|
+
|
|
38
|
+
export function VibesLogin({
|
|
39
|
+
title = "Sign in",
|
|
40
|
+
subtitle = "Sign in to continue.",
|
|
41
|
+
}: {
|
|
42
|
+
title?: string
|
|
43
|
+
subtitle?: string
|
|
44
|
+
}) {
|
|
45
|
+
const { sendSignInCode, verifySignInCode, signInWithPasskey } = useAuth()
|
|
46
|
+
const [email, setEmail] = useState("")
|
|
47
|
+
const [stage, setStage] = useState<"email" | "code">("email")
|
|
48
|
+
const [code, setCode] = useState("")
|
|
49
|
+
const [error, setError] = useState<string | null>(null)
|
|
50
|
+
// Track *which* action is in flight so each button shows its own spinner.
|
|
51
|
+
const [pending, setPending] = useState<"send" | "verify" | "passkey" | null>(null)
|
|
52
|
+
const busy = pending !== null
|
|
53
|
+
// Guard against double-verify when the 6th digit lands while a submit is in flight.
|
|
54
|
+
const verifyingRef = useRef(false)
|
|
55
|
+
|
|
56
|
+
const mailApp = mailAppForEmail(email)
|
|
57
|
+
|
|
58
|
+
async function onSendCode(e: FormEvent) {
|
|
59
|
+
e.preventDefault()
|
|
60
|
+
if (!email.trim() || busy) return
|
|
61
|
+
setPending("send"); setError(null)
|
|
62
|
+
try {
|
|
63
|
+
await sendSignInCode(email.trim())
|
|
64
|
+
setCode("")
|
|
65
|
+
setStage("code")
|
|
66
|
+
} catch (err) {
|
|
67
|
+
setError(err instanceof Error ? err.message : "Failed to send code")
|
|
68
|
+
} finally {
|
|
69
|
+
setPending(null)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function verify(next: string) {
|
|
74
|
+
if (verifyingRef.current) return
|
|
75
|
+
verifyingRef.current = true
|
|
76
|
+
setPending("verify"); setError(null)
|
|
77
|
+
try {
|
|
78
|
+
await verifySignInCode(email.trim(), next)
|
|
79
|
+
// Success: the session signal refetches useSession; the guard/dialog
|
|
80
|
+
// hosting this component unmounts it. Nothing left to do here.
|
|
81
|
+
} catch (err) {
|
|
82
|
+
setError(err instanceof Error ? err.message : "Invalid code")
|
|
83
|
+
setCode("")
|
|
84
|
+
} finally {
|
|
85
|
+
verifyingRef.current = false
|
|
86
|
+
setPending(null)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function onCodeChange(raw: string) {
|
|
91
|
+
const digits = raw.replace(/\D/g, "").slice(0, CODE_LENGTH)
|
|
92
|
+
setCode(digits)
|
|
93
|
+
if (digits.length === CODE_LENGTH && !busy) void verify(digits)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function onResend() {
|
|
97
|
+
if (busy) return
|
|
98
|
+
setPending("send"); setError(null)
|
|
99
|
+
try {
|
|
100
|
+
await sendSignInCode(email.trim())
|
|
101
|
+
setCode("")
|
|
102
|
+
} catch (err) {
|
|
103
|
+
setError(err instanceof Error ? err.message : "Failed to send code")
|
|
104
|
+
} finally {
|
|
105
|
+
setPending(null)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function onPasskey() {
|
|
110
|
+
if (busy) return
|
|
111
|
+
setPending("passkey"); setError(null)
|
|
112
|
+
try {
|
|
113
|
+
await signInWithPasskey()
|
|
114
|
+
} catch (err) {
|
|
115
|
+
setError(err instanceof Error ? err.message : "Passkey sign-in failed")
|
|
116
|
+
} finally {
|
|
117
|
+
setPending(null)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
<div className="flex min-h-[60vh] w-full items-center justify-center p-6">
|
|
123
|
+
<div className="w-full max-w-sm space-y-6 rounded-2xl border bg-card p-6 text-card-foreground shadow-sm">
|
|
124
|
+
<div className="space-y-1.5">
|
|
125
|
+
<h2 className="text-lg font-semibold tracking-tight">{title}</h2>
|
|
126
|
+
{stage === "code" ? (
|
|
127
|
+
<p className="text-sm text-muted-foreground">
|
|
128
|
+
Enter the code sent to{" "}
|
|
129
|
+
<span className="font-medium text-foreground">{email.trim()}</span>.
|
|
130
|
+
</p>
|
|
131
|
+
) : subtitle ? (
|
|
132
|
+
<p className="text-sm text-muted-foreground">{subtitle}</p>
|
|
133
|
+
) : null}
|
|
134
|
+
</div>
|
|
135
|
+
|
|
136
|
+
{stage === "code" ? (
|
|
137
|
+
<div className="space-y-3">
|
|
138
|
+
<form
|
|
139
|
+
onSubmit={(e) => {
|
|
140
|
+
e.preventDefault()
|
|
141
|
+
if (code.length === CODE_LENGTH && !busy) void verify(code)
|
|
142
|
+
}}
|
|
143
|
+
className="space-y-3"
|
|
144
|
+
>
|
|
145
|
+
<input
|
|
146
|
+
type="text"
|
|
147
|
+
inputMode="numeric"
|
|
148
|
+
autoComplete="one-time-code"
|
|
149
|
+
pattern="[0-9]*"
|
|
150
|
+
maxLength={CODE_LENGTH}
|
|
151
|
+
placeholder="000000"
|
|
152
|
+
value={code}
|
|
153
|
+
onChange={(e) => onCodeChange(e.target.value)}
|
|
154
|
+
disabled={busy}
|
|
155
|
+
autoFocus
|
|
156
|
+
aria-label="Sign-in code"
|
|
157
|
+
className="flex h-12 w-full rounded-md border bg-background px-3 py-2 text-center font-mono text-xl tracking-[0.5em] shadow-sm placeholder:text-muted-foreground/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
|
158
|
+
/>
|
|
159
|
+
<button
|
|
160
|
+
type="submit"
|
|
161
|
+
disabled={busy || code.length !== CODE_LENGTH}
|
|
162
|
+
className="inline-flex h-10 w-full items-center justify-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
|
|
163
|
+
>
|
|
164
|
+
{pending === "verify" ? (
|
|
165
|
+
<>
|
|
166
|
+
<Spinner className="h-4 w-4" />
|
|
167
|
+
Signing in…
|
|
168
|
+
</>
|
|
169
|
+
) : (
|
|
170
|
+
"Sign in"
|
|
171
|
+
)}
|
|
172
|
+
</button>
|
|
173
|
+
</form>
|
|
174
|
+
|
|
175
|
+
{mailApp ? (
|
|
176
|
+
<a
|
|
177
|
+
href={mailApp.url}
|
|
178
|
+
target="_blank"
|
|
179
|
+
rel="noreferrer"
|
|
180
|
+
className="inline-flex h-10 w-full items-center justify-center gap-2 rounded-md border bg-background px-4 py-2 text-sm font-medium shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground"
|
|
181
|
+
>
|
|
182
|
+
{mailApp.label}
|
|
183
|
+
</a>
|
|
184
|
+
) : null}
|
|
185
|
+
|
|
186
|
+
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
|
187
|
+
<button
|
|
188
|
+
type="button"
|
|
189
|
+
onClick={onResend}
|
|
190
|
+
disabled={busy}
|
|
191
|
+
className="transition-colors hover:text-foreground disabled:opacity-50"
|
|
192
|
+
>
|
|
193
|
+
{pending === "send" ? "Sending…" : "Resend code"}
|
|
194
|
+
</button>
|
|
195
|
+
<button
|
|
196
|
+
type="button"
|
|
197
|
+
onClick={() => {
|
|
198
|
+
setStage("email"); setCode(""); setError(null)
|
|
199
|
+
}}
|
|
200
|
+
disabled={busy}
|
|
201
|
+
className="transition-colors hover:text-foreground disabled:opacity-50"
|
|
202
|
+
>
|
|
203
|
+
Change email
|
|
204
|
+
</button>
|
|
205
|
+
</div>
|
|
206
|
+
</div>
|
|
207
|
+
) : (
|
|
208
|
+
<form onSubmit={onSendCode} className="space-y-3">
|
|
209
|
+
<input
|
|
210
|
+
type="email"
|
|
211
|
+
required
|
|
212
|
+
placeholder="you@example.com"
|
|
213
|
+
value={email}
|
|
214
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
215
|
+
disabled={busy}
|
|
216
|
+
className="flex h-10 w-full rounded-md border bg-background px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
|
217
|
+
/>
|
|
218
|
+
<button
|
|
219
|
+
type="submit"
|
|
220
|
+
disabled={busy}
|
|
221
|
+
className="inline-flex h-10 w-full items-center justify-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
|
|
222
|
+
>
|
|
223
|
+
{pending === "send" ? (
|
|
224
|
+
<>
|
|
225
|
+
<Spinner className="h-4 w-4" />
|
|
226
|
+
Sending…
|
|
227
|
+
</>
|
|
228
|
+
) : (
|
|
229
|
+
"Send code"
|
|
230
|
+
)}
|
|
231
|
+
</button>
|
|
232
|
+
</form>
|
|
233
|
+
)}
|
|
234
|
+
|
|
235
|
+
{stage === "email" ? (
|
|
236
|
+
<>
|
|
237
|
+
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
|
238
|
+
<div className="h-px flex-1 bg-border" />
|
|
239
|
+
or
|
|
240
|
+
<div className="h-px flex-1 bg-border" />
|
|
241
|
+
</div>
|
|
242
|
+
|
|
243
|
+
<button
|
|
244
|
+
type="button"
|
|
245
|
+
onClick={onPasskey}
|
|
246
|
+
disabled={busy}
|
|
247
|
+
className="inline-flex h-10 w-full items-center justify-center gap-2 rounded-md border bg-background px-4 py-2 text-sm font-medium shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
|
|
248
|
+
>
|
|
249
|
+
{pending === "passkey" ? (
|
|
250
|
+
<>
|
|
251
|
+
<Spinner className="h-4 w-4" />
|
|
252
|
+
Signing in…
|
|
253
|
+
</>
|
|
254
|
+
) : (
|
|
255
|
+
"Sign in with passkey"
|
|
256
|
+
)}
|
|
257
|
+
</button>
|
|
258
|
+
</>
|
|
259
|
+
) : null}
|
|
260
|
+
|
|
261
|
+
{error ? (
|
|
262
|
+
<div className="text-sm text-destructive">{error}</div>
|
|
263
|
+
) : null}
|
|
264
|
+
</div>
|
|
265
|
+
</div>
|
|
266
|
+
)
|
|
267
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// @omg-dev/sdk/auth — "Open mail app" helper.
|
|
2
|
+
//
|
|
3
|
+
// Maps an email address to its webmail inbox so the post-send screen can
|
|
4
|
+
// offer a one-tap jump to wherever the code landed. Webmail URLs (not
|
|
5
|
+
// platform URL schemes) on purpose: they work from regular tabs AND from
|
|
6
|
+
// installed PWAs on every OS, and on mobile the OS hands them to the
|
|
7
|
+
// installed native app when one is registered for the domain.
|
|
8
|
+
|
|
9
|
+
export interface MailApp {
|
|
10
|
+
/** Stable provider key, e.g. "gmail" */
|
|
11
|
+
provider: string
|
|
12
|
+
/** Short button label, e.g. "Open Gmail" */
|
|
13
|
+
label: string
|
|
14
|
+
/** Webmail inbox URL */
|
|
15
|
+
url: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const PROVIDERS: Array<{ pattern: RegExp; app: MailApp }> = [
|
|
19
|
+
{
|
|
20
|
+
pattern: /^(gmail|googlemail)\./,
|
|
21
|
+
app: { provider: "gmail", label: "Open Gmail", url: "https://mail.google.com/mail/u/0/" },
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
pattern: /^(outlook|hotmail|live|msn)\./,
|
|
25
|
+
app: { provider: "outlook", label: "Open Outlook", url: "https://outlook.live.com/mail/" },
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
pattern: /^(yahoo|ymail|rocketmail)\./,
|
|
29
|
+
app: { provider: "yahoo", label: "Open Yahoo", url: "https://mail.yahoo.com/" },
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
pattern: /^(icloud|me|mac)\./,
|
|
33
|
+
app: { provider: "icloud", label: "Open iCloud Mail", url: "https://www.icloud.com/mail/" },
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
pattern: /^(proton|protonmail|pm)\./,
|
|
37
|
+
app: { provider: "proton", label: "Open Proton", url: "https://mail.proton.me/" },
|
|
38
|
+
},
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Returns the webmail app for the address's provider, or null for custom
|
|
43
|
+
* domains (no reliable way to know where their mail is hosted).
|
|
44
|
+
*/
|
|
45
|
+
export function mailAppForEmail(email: string): MailApp | null {
|
|
46
|
+
const domain = email.split("@")[1]?.toLowerCase().trim() ?? ""
|
|
47
|
+
if (!domain) return null
|
|
48
|
+
for (const { pattern, app } of PROVIDERS) {
|
|
49
|
+
if (pattern.test(domain)) return app
|
|
50
|
+
}
|
|
51
|
+
return null
|
|
52
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
// @omg-dev/sdk/auth — React provider and hooks
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext, useEffect, useState, useCallback, useMemo, type ReactNode } from "react"
|
|
4
|
+
import type { VibesAuthClient, VibesUser } from "./client"
|
|
5
|
+
import { createVibesAuth, deriveAppId } from "./client"
|
|
6
|
+
import { setAuthContext } from "./bridge"
|
|
7
|
+
import { VibesAuthAutoPrompt } from "./auto-prompt"
|
|
8
|
+
|
|
9
|
+
export interface VibesAuthContextValue {
|
|
10
|
+
/** Current user, or null if not signed in */
|
|
11
|
+
user: VibesUser | null
|
|
12
|
+
/** Whether the session is still loading */
|
|
13
|
+
loading: boolean
|
|
14
|
+
/** Current JWT token for API calls */
|
|
15
|
+
token: string | null
|
|
16
|
+
/** Sign out and clear session */
|
|
17
|
+
signOut: () => Promise<void>
|
|
18
|
+
/** Refresh the JWT token */
|
|
19
|
+
refreshToken: () => Promise<string | null>
|
|
20
|
+
/** The underlying auth client for advanced use (magic link, passkey sign-in) */
|
|
21
|
+
client: VibesAuthClient
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const VibesAuthContext = createContext<VibesAuthContextValue | null>(null)
|
|
25
|
+
|
|
26
|
+
export function VibesAuthProvider({
|
|
27
|
+
client: providedClient,
|
|
28
|
+
appId,
|
|
29
|
+
authUrl,
|
|
30
|
+
autoPrompt = true,
|
|
31
|
+
children,
|
|
32
|
+
}: {
|
|
33
|
+
/** Pre-built auth client. When omitted, one is built from `appId`/`authUrl`
|
|
34
|
+
* (or auto-derived from window.location.host for *.omgs.app /
|
|
35
|
+
* *.apps.omg.dev). */
|
|
36
|
+
client?: VibesAuthClient
|
|
37
|
+
appId?: string
|
|
38
|
+
authUrl?: string
|
|
39
|
+
/**
|
|
40
|
+
* Auto-open the login UI whenever any SDK request hits the server's
|
|
41
|
+
* auth-required signal (REST 401 / WS `auth_required`). On by default —
|
|
42
|
+
* pass `false` to manage sign-in entirely through <VibesAuthGuard> /
|
|
43
|
+
* <VibesLogin> / useAuth() instead.
|
|
44
|
+
*/
|
|
45
|
+
autoPrompt?: boolean
|
|
46
|
+
children: ReactNode
|
|
47
|
+
}) {
|
|
48
|
+
const client = useMemo(() => {
|
|
49
|
+
if (providedClient) return providedClient
|
|
50
|
+
const id = appId ?? deriveAppId() ?? "local"
|
|
51
|
+
return createVibesAuth({ appId: id, authUrl })
|
|
52
|
+
}, [providedClient, appId, authUrl])
|
|
53
|
+
|
|
54
|
+
const { data: session, isPending } = client.authClient.useSession()
|
|
55
|
+
const [token, setToken] = useState<string | null>(null)
|
|
56
|
+
// True once the session check + first getToken() attempt have settled. Data
|
|
57
|
+
// hooks read this (via the bridge) to tell a transient startup
|
|
58
|
+
// `auth_required` apart from a genuinely signed-out user.
|
|
59
|
+
const [authReady, setAuthReady] = useState(false)
|
|
60
|
+
|
|
61
|
+
// Fetch JWT when session becomes available. Gate on isPending so we don't
|
|
62
|
+
// mark auth "ready" (or clear the token) while the session is still
|
|
63
|
+
// resolving — that window is exactly when the token race surfaced a scary
|
|
64
|
+
// error before.
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (isPending) return
|
|
67
|
+
if (!session?.user) {
|
|
68
|
+
setToken(null)
|
|
69
|
+
client.clearToken()
|
|
70
|
+
setAuthReady(true)
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
let cancelled = false
|
|
74
|
+
client.getToken().then((t) => {
|
|
75
|
+
if (cancelled) return
|
|
76
|
+
setToken(t)
|
|
77
|
+
setAuthReady(true)
|
|
78
|
+
})
|
|
79
|
+
return () => {
|
|
80
|
+
cancelled = true
|
|
81
|
+
}
|
|
82
|
+
}, [isPending, session?.user?.id, client])
|
|
83
|
+
|
|
84
|
+
// Mirror auth state into the module-scoped bridge so useCollection (which
|
|
85
|
+
// doesn't take a client prop) can attach Authorization on every fetch.
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
setAuthContext({
|
|
88
|
+
user: session?.user
|
|
89
|
+
? {
|
|
90
|
+
id: session.user.id,
|
|
91
|
+
email: session.user.email,
|
|
92
|
+
name: session.user.name ?? undefined,
|
|
93
|
+
}
|
|
94
|
+
: null,
|
|
95
|
+
token,
|
|
96
|
+
authReady,
|
|
97
|
+
})
|
|
98
|
+
}, [session?.user?.id, session?.user?.email, session?.user?.name, token, authReady])
|
|
99
|
+
|
|
100
|
+
const signOut = useCallback(async () => {
|
|
101
|
+
await client.authClient.signOut()
|
|
102
|
+
client.clearToken()
|
|
103
|
+
setToken(null)
|
|
104
|
+
}, [client])
|
|
105
|
+
|
|
106
|
+
const refreshToken = useCallback(async () => {
|
|
107
|
+
const t = await client.getToken()
|
|
108
|
+
setToken(t)
|
|
109
|
+
return t
|
|
110
|
+
}, [client])
|
|
111
|
+
|
|
112
|
+
const user: VibesUser | null = session?.user
|
|
113
|
+
? {
|
|
114
|
+
id: session.user.id,
|
|
115
|
+
email: session.user.email,
|
|
116
|
+
name: session.user.name ?? undefined,
|
|
117
|
+
}
|
|
118
|
+
: null
|
|
119
|
+
|
|
120
|
+
const value = useMemo<VibesAuthContextValue>(
|
|
121
|
+
() => ({ user, loading: isPending, token, signOut, refreshToken, client }),
|
|
122
|
+
[user?.id, isPending, token, signOut, refreshToken, client]
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<VibesAuthContext.Provider value={value}>
|
|
127
|
+
{children}
|
|
128
|
+
{autoPrompt ? <VibesAuthAutoPrompt /> : null}
|
|
129
|
+
</VibesAuthContext.Provider>
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function useVibesAuth(): VibesAuthContextValue {
|
|
134
|
+
const ctx = useContext(VibesAuthContext)
|
|
135
|
+
if (!ctx) {
|
|
136
|
+
throw new Error("useVibesAuth must be used within <VibesAuthProvider>")
|
|
137
|
+
}
|
|
138
|
+
return ctx
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Hook that returns just the JWT token, refreshing it if needed.
|
|
143
|
+
* Useful for passing to fetch calls.
|
|
144
|
+
*/
|
|
145
|
+
export function useVibesToken(): string | null {
|
|
146
|
+
const { token } = useVibesAuth()
|
|
147
|
+
return token
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── Friendly aliases ──────────────────────────────────────────────────────────
|
|
151
|
+
// `useUser` and `useAuth` map to the documented public surface; they thin-wrap
|
|
152
|
+
// `useVibesAuth` so app code reads naturally without renaming.
|
|
153
|
+
|
|
154
|
+
export function useUser(): VibesUser | null {
|
|
155
|
+
return useVibesAuth().user
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export interface UseAuthReturn {
|
|
159
|
+
user: VibesUser | null
|
|
160
|
+
loading: boolean
|
|
161
|
+
token: string | null
|
|
162
|
+
signOut: () => Promise<void>
|
|
163
|
+
/** Send a magic-link email. Resolves once the request is accepted. */
|
|
164
|
+
signInWithMagicLink: (email: string, callbackURL?: string) => Promise<void>
|
|
165
|
+
/**
|
|
166
|
+
* Email a 6-digit sign-in code. Pair with `verifySignInCode` — the session
|
|
167
|
+
* is created in-place (no redirect), so it works in installed PWAs and
|
|
168
|
+
* across devices where a clicked link wouldn't.
|
|
169
|
+
*/
|
|
170
|
+
sendSignInCode: (email: string) => Promise<void>
|
|
171
|
+
/** Verify the emailed code and sign in. Rejects on a wrong/expired code. */
|
|
172
|
+
verifySignInCode: (email: string, code: string) => Promise<void>
|
|
173
|
+
/** Trigger passkey sign-in. Browser prompts for credential. */
|
|
174
|
+
signInWithPasskey: () => Promise<void>
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function useAuth(): UseAuthReturn {
|
|
178
|
+
const { user, loading, token, signOut, client } = useVibesAuth()
|
|
179
|
+
|
|
180
|
+
const signInWithMagicLink = useCallback(
|
|
181
|
+
async (email: string, callbackURL?: string) => {
|
|
182
|
+
const cb = callbackURL ?? (typeof window !== "undefined" ? window.location.href : undefined)
|
|
183
|
+
await fetch(`${client.authUrl}/api/auth/sign-in/magic-link`, {
|
|
184
|
+
method: "POST",
|
|
185
|
+
credentials: "include",
|
|
186
|
+
headers: { "Content-Type": "application/json" },
|
|
187
|
+
body: JSON.stringify({ email, callbackURL: cb }),
|
|
188
|
+
})
|
|
189
|
+
},
|
|
190
|
+
[client]
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
// Go through the better-auth client (not raw fetch) so the email-otp
|
|
194
|
+
// plugin's atomListeners fire $sessionSignal on verify and useSession
|
|
195
|
+
// refetches immediately.
|
|
196
|
+
const sendSignInCode = useCallback(
|
|
197
|
+
async (email: string) => {
|
|
198
|
+
const c = client.authClient as unknown as {
|
|
199
|
+
emailOtp?: {
|
|
200
|
+
sendVerificationOtp?: (args: { email: string; type: "sign-in" }) =>
|
|
201
|
+
Promise<{ error?: { message?: string } | null }>
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if (typeof c.emailOtp?.sendVerificationOtp !== "function") {
|
|
205
|
+
throw new Error("Email OTP plugin not loaded")
|
|
206
|
+
}
|
|
207
|
+
const res = await c.emailOtp.sendVerificationOtp({ email, type: "sign-in" })
|
|
208
|
+
if (res.error) throw new Error(res.error.message ?? "Failed to send code")
|
|
209
|
+
},
|
|
210
|
+
[client]
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
const verifySignInCode = useCallback(
|
|
214
|
+
async (email: string, code: string) => {
|
|
215
|
+
const c = client.authClient as unknown as {
|
|
216
|
+
signIn?: {
|
|
217
|
+
emailOtp?: (args: { email: string; otp: string }) =>
|
|
218
|
+
Promise<{ error?: { message?: string; code?: string } | null }>
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (typeof c.signIn?.emailOtp !== "function") {
|
|
222
|
+
throw new Error("Email OTP plugin not loaded")
|
|
223
|
+
}
|
|
224
|
+
const res = await c.signIn.emailOtp({ email, otp: code })
|
|
225
|
+
if (res.error) {
|
|
226
|
+
const code_ = res.error.code
|
|
227
|
+
if (code_ === "OTP_EXPIRED") throw new Error("That code expired — request a new one.")
|
|
228
|
+
if (code_ === "TOO_MANY_ATTEMPTS") throw new Error("Too many attempts — request a new code.")
|
|
229
|
+
throw new Error(res.error.message ?? "Invalid code")
|
|
230
|
+
}
|
|
231
|
+
},
|
|
232
|
+
[client]
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
const signInWithPasskey = useCallback(async () => {
|
|
236
|
+
// better-auth client exposes signIn.passkey when the plugin is loaded.
|
|
237
|
+
const c = client.authClient as unknown as {
|
|
238
|
+
signIn?: { passkey?: () => Promise<unknown> }
|
|
239
|
+
}
|
|
240
|
+
if (typeof c.signIn?.passkey === "function") {
|
|
241
|
+
await c.signIn.passkey()
|
|
242
|
+
} else {
|
|
243
|
+
throw new Error("Passkey plugin not loaded")
|
|
244
|
+
}
|
|
245
|
+
}, [client])
|
|
246
|
+
|
|
247
|
+
return { user, loading, token, signOut, signInWithMagicLink, sendSignInCode, verifySignInCode, signInWithPasskey }
|
|
248
|
+
}
|