@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.
@@ -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
+ }