@stnd/account 0.1.1

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,233 @@
1
+ <script>
2
+ import { accountClient } from "@stnd/account/client";
3
+ import { setUser } from "@stnd/account/models/User.js";
4
+
5
+ // Props
6
+ let { close, back } = $props();
7
+
8
+ // State
9
+ let email = $state("");
10
+ let otp = $state("");
11
+ let loading = $state(false);
12
+ let step = $state("email"); // "email" | "otp"
13
+
14
+ // Show passkey button only if user has previously set one up on this device
15
+ const hasPasskey = $state(
16
+ typeof localStorage !== "undefined" &&
17
+ !!localStorage.getItem("stnd:hasPasskey"),
18
+ );
19
+
20
+ async function hydrateUserStore() {
21
+ try {
22
+ const { data } = await accountClient.getSession();
23
+ if (data?.user) {
24
+ setUser({ ...data.user, isAuthenticated: true }, data.session);
25
+ }
26
+ } catch {
27
+ // silently ignore — page reload will fix it
28
+ }
29
+ }
30
+
31
+ async function suggestPasskeyIfNone() {
32
+ try {
33
+ const { data } = await accountClient.passkey.listPasskeys();
34
+ if (!data || data.length === 0) {
35
+ setTimeout(() => {
36
+ window.toast(
37
+ "Tip: Set up a passkey (FaceID / TouchID) for instant login next time.",
38
+ "info",
39
+ 8000,
40
+ { label: "Set up", view: "::account-settings" },
41
+ );
42
+ }, 1500);
43
+ } else {
44
+ localStorage.setItem("stnd:hasPasskey", "1");
45
+ }
46
+ } catch {
47
+ // silently ignore
48
+ }
49
+ }
50
+
51
+ async function handleEmailLogin() {
52
+ if (!email.includes("@")) {
53
+ window.toast("Please enter a valid email address.", "error");
54
+ return;
55
+ }
56
+
57
+ loading = true;
58
+ try {
59
+ const { data, error } = await accountClient.emailOtp.sendVerificationOtp({
60
+ email,
61
+ type: "sign-in",
62
+ });
63
+
64
+ if (error) {
65
+ window.toast(error.message || "Failed to send login link.", "error");
66
+ } else {
67
+ window.toast("Check your email for the code!", "success");
68
+ step = "otp";
69
+ }
70
+ } catch (e) {
71
+ window.toast("Network error. Please try again.", "error");
72
+ console.error(e);
73
+ } finally {
74
+ loading = false;
75
+ }
76
+ }
77
+
78
+ async function handleVerifyOTP() {
79
+ if (otp.length !== 6) {
80
+ window.toast("Please enter the 6-digit code.", "error");
81
+ return;
82
+ }
83
+
84
+ loading = true;
85
+ try {
86
+ const { data, error } = await accountClient.signIn.emailOtp({
87
+ email,
88
+ otp,
89
+ type: "sign-in",
90
+ callbackURL: window.location.href,
91
+ });
92
+
93
+ if (error) {
94
+ window.toast(error.message || "Invalid code.", "error");
95
+ } else {
96
+ window.toast("Login successful!", "success");
97
+ hydrateUserStore();
98
+ suggestPasskeyIfNone();
99
+ setTimeout(() => close(), 1000);
100
+ }
101
+ } catch (e) {
102
+ window.toast("Network error. Please try again.", "error");
103
+ console.error(e);
104
+ } finally {
105
+ loading = false;
106
+ }
107
+ }
108
+
109
+ async function handleSocialLogin(provider) {
110
+ loading = true;
111
+ try {
112
+ const { data, error } = await accountClient.signIn.social({
113
+ provider,
114
+ callbackURL: window.location.href,
115
+ });
116
+
117
+ if (error) {
118
+ window.toast(error.message || `Failed to initiate ${provider} login`, "error");
119
+ loading = false;
120
+ }
121
+ // Social providers redirect — passkey suggestion handled on page reload
122
+ } catch (e) {
123
+ window.toast("Network error. Please try again.", "error");
124
+ console.error(e);
125
+ loading = false;
126
+ }
127
+ }
128
+
129
+ async function handlePasskeyLogin() {
130
+ loading = true;
131
+ try {
132
+ const { data, error } = await accountClient.signIn.passkey({
133
+ fetchOptions: {
134
+ onSuccess: () => {
135
+ localStorage.setItem("stnd:hasPasskey", "1");
136
+ window.toast("Passkey verification successful!", "success");
137
+ hydrateUserStore();
138
+ setTimeout(() => {
139
+ close();
140
+ }, 1000);
141
+ },
142
+ },
143
+ });
144
+
145
+ if (error) {
146
+ window.toast("Passkey login failed.", "error");
147
+ }
148
+ } catch (e) {
149
+ window.toast("Network error. Please try again.", "error");
150
+ console.error(e);
151
+ } finally {
152
+ loading = false;
153
+ }
154
+ }
155
+
156
+ function handleKey(e) {
157
+ if (e.key === "Enter") {
158
+ if (step === "email") {
159
+ handleEmailLogin();
160
+ } else {
161
+ handleVerifyOTP();
162
+ }
163
+ }
164
+ }
165
+ </script>
166
+
167
+ <div class="p pt-2">
168
+ {#if step === "email"}
169
+ <div class="text-center">
170
+ <h3 class="text-center">Log in to your account</h3>
171
+
172
+ <div class="flex justify-center no-rhythm">
173
+ <button onclick={() => handleSocialLogin("google")} disabled={loading} class="h-2 px">
174
+ Sign in with Google
175
+ </button>
176
+ {#if hasPasskey}
177
+ <button onclick={handlePasskeyLogin} disabled={loading} class="h-2 px">
178
+ Passkey (FaceID / TouchID)
179
+ </button>
180
+ {/if}
181
+ </div>
182
+ <hr class="my" />
183
+
184
+ <div class="flex justify-center no-rhythm">
185
+ <input
186
+ type="email"
187
+ bind:value={email}
188
+ placeholder="name@example.com"
189
+ onkeydown={handleKey}
190
+ disabled={loading}
191
+ class="mb-0 w-12"
192
+ />
193
+ <button onclick={handleEmailLogin} disabled={loading}>
194
+ {loading ? "Sending..." : "Continue with Email"}
195
+ </button>
196
+ </div>
197
+ </div>
198
+ {:else}
199
+ <div class="text-center flex-col">
200
+ <h3>Enter verification code</h3>
201
+ <p class="text-sm text-muted">
202
+ We sent a 6-digit code to {email}
203
+ </p>
204
+ <input
205
+ type="text"
206
+ inputmode="numeric"
207
+ pattern="[0-9]*"
208
+ maxlength="6"
209
+ bind:value={otp}
210
+ placeholder="000000"
211
+ onkeydown={handleKey}
212
+ disabled={loading}
213
+ class="m-auto text-center w-10"
214
+ style="letter-spacing: 0.5rem; font-size: 1.5rem; font-weight: bold;"
215
+ />
216
+
217
+ <button
218
+ onclick={handleVerifyOTP}
219
+ disabled={loading || otp.length !== 6}
220
+ class="mx-auto secondary"
221
+ >
222
+ {loading ? "Verifying..." : "Verify Code"}
223
+ </button>
224
+ <button
225
+ class="ghost small text-subtle"
226
+ onclick={() => (step = "email")}
227
+ disabled={loading}
228
+ >
229
+ Use a different email
230
+ </button>
231
+ </div>
232
+ {/if}
233
+ </div>