@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.
- package/CHANGELOG.md +8 -0
- package/README.md +146 -0
- package/actions/system.js +28 -0
- package/auth.js +235 -0
- package/client.svelte.js +19 -0
- package/index.module.js +59 -0
- package/middleware.js +59 -0
- package/models/User.js +107 -0
- package/package.json +36 -0
- package/route.js +25 -0
- package/schema.sql +70 -0
- package/ui/Avatar.svelte +115 -0
- package/ui/UserMenu.svelte +225 -0
- package/ui/preferences.js +120 -0
- package/views/LoginView.svelte +233 -0
|
@@ -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>
|