@yackey-labs/yauth-ui-solidjs 0.1.0
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/index.js +909 -0
- package/package.json +35 -0
- package/src/components/change-password-form.tsx +140 -0
- package/src/components/consent-screen.tsx +152 -0
- package/src/components/forgot-password-form.tsx +83 -0
- package/src/components/login-form.tsx +128 -0
- package/src/components/magic-link-form.tsx +84 -0
- package/src/components/mfa-challenge.tsx +83 -0
- package/src/components/mfa-setup.tsx +158 -0
- package/src/components/oauth-buttons.tsx +30 -0
- package/src/components/passkey-button.tsx +98 -0
- package/src/components/profile-settings.tsx +406 -0
- package/src/components/register-form.tsx +119 -0
- package/src/components/reset-password-form.tsx +83 -0
- package/src/components/verify-email.tsx +54 -0
- package/src/index.ts +14 -0
- package/src/provider.test.tsx +159 -0
- package/src/provider.tsx +73 -0
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@yackey-labs/yauth-ui-solidjs",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"module": "dist/index.js",
|
|
7
|
+
"types": "src/index.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./dist/index.js",
|
|
11
|
+
"types": "./src/index.ts",
|
|
12
|
+
"default": "./src/index.ts"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"src"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "vite build",
|
|
21
|
+
"typecheck": "tsc --noEmit",
|
|
22
|
+
"test": "bun test --conditions browser"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@simplewebauthn/browser": "^13.0.0",
|
|
26
|
+
"@yackey-labs/yauth-client": "0.1.0",
|
|
27
|
+
"@yackey-labs/yauth-shared": "0.1.0",
|
|
28
|
+
"solid-js": "^1.9.5"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"typescript": "^5.7.2",
|
|
32
|
+
"vite": "^7.3.1",
|
|
33
|
+
"vite-plugin-solid": "^2.11.10"
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { type Component, createSignal } from "solid-js";
|
|
2
|
+
import { Show } from "solid-js/web";
|
|
3
|
+
import { useYAuth } from "../provider";
|
|
4
|
+
|
|
5
|
+
export interface ChangePasswordFormProps {
|
|
6
|
+
onSuccess?: () => void;
|
|
7
|
+
onError?: (error: Error) => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const ChangePasswordForm: Component<ChangePasswordFormProps> = (
|
|
11
|
+
props,
|
|
12
|
+
) => {
|
|
13
|
+
const { client } = useYAuth();
|
|
14
|
+
const [currentPassword, setCurrentPassword] = createSignal("");
|
|
15
|
+
const [newPassword, setNewPassword] = createSignal("");
|
|
16
|
+
const [confirmPassword, setConfirmPassword] = createSignal("");
|
|
17
|
+
const [error, setError] = createSignal<string | null>(null);
|
|
18
|
+
const [success, setSuccess] = createSignal(false);
|
|
19
|
+
const [loading, setLoading] = createSignal(false);
|
|
20
|
+
|
|
21
|
+
const handleSubmit = async (e: SubmitEvent) => {
|
|
22
|
+
e.preventDefault();
|
|
23
|
+
setError(null);
|
|
24
|
+
setSuccess(false);
|
|
25
|
+
|
|
26
|
+
const form = e.currentTarget as HTMLFormElement;
|
|
27
|
+
const formData = new FormData(form);
|
|
28
|
+
const currentPw =
|
|
29
|
+
(formData.get("current_password") as string) || currentPassword();
|
|
30
|
+
const newPw = (formData.get("new_password") as string) || newPassword();
|
|
31
|
+
const confirmPw =
|
|
32
|
+
(formData.get("confirm_password") as string) || confirmPassword();
|
|
33
|
+
|
|
34
|
+
if (newPw !== confirmPw) {
|
|
35
|
+
setError("Passwords do not match");
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
setLoading(true);
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
await client.emailPassword.changePassword(currentPw, newPw);
|
|
43
|
+
setSuccess(true);
|
|
44
|
+
setCurrentPassword("");
|
|
45
|
+
setNewPassword("");
|
|
46
|
+
setConfirmPassword("");
|
|
47
|
+
props.onSuccess?.();
|
|
48
|
+
} catch (err) {
|
|
49
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
50
|
+
setError(error.message);
|
|
51
|
+
props.onError?.(error);
|
|
52
|
+
} finally {
|
|
53
|
+
setLoading(false);
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<form class="space-y-4" onSubmit={handleSubmit}>
|
|
59
|
+
<Show when={error()}>
|
|
60
|
+
<div class="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
|
61
|
+
{error()}
|
|
62
|
+
</div>
|
|
63
|
+
</Show>
|
|
64
|
+
|
|
65
|
+
<Show when={success()}>
|
|
66
|
+
<div class="rounded-md bg-emerald-500/10 px-3 py-2 text-sm text-emerald-600 dark:text-emerald-400">
|
|
67
|
+
Password changed successfully.
|
|
68
|
+
</div>
|
|
69
|
+
</Show>
|
|
70
|
+
|
|
71
|
+
<div class="space-y-2">
|
|
72
|
+
<label
|
|
73
|
+
class="text-sm font-medium leading-none"
|
|
74
|
+
for="yauth-current-password"
|
|
75
|
+
>
|
|
76
|
+
Current password
|
|
77
|
+
</label>
|
|
78
|
+
<input
|
|
79
|
+
class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm"
|
|
80
|
+
id="yauth-current-password"
|
|
81
|
+
name="current_password"
|
|
82
|
+
type="password"
|
|
83
|
+
value={currentPassword()}
|
|
84
|
+
onInput={(e) => setCurrentPassword(e.currentTarget.value)}
|
|
85
|
+
required
|
|
86
|
+
autocomplete="current-password"
|
|
87
|
+
disabled={loading()}
|
|
88
|
+
/>
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
<div class="space-y-2">
|
|
92
|
+
<label
|
|
93
|
+
class="text-sm font-medium leading-none"
|
|
94
|
+
for="yauth-new-password"
|
|
95
|
+
>
|
|
96
|
+
New password
|
|
97
|
+
</label>
|
|
98
|
+
<input
|
|
99
|
+
class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm"
|
|
100
|
+
id="yauth-new-password"
|
|
101
|
+
name="new_password"
|
|
102
|
+
type="password"
|
|
103
|
+
value={newPassword()}
|
|
104
|
+
onInput={(e) => setNewPassword(e.currentTarget.value)}
|
|
105
|
+
required
|
|
106
|
+
autocomplete="new-password"
|
|
107
|
+
disabled={loading()}
|
|
108
|
+
/>
|
|
109
|
+
</div>
|
|
110
|
+
|
|
111
|
+
<div class="space-y-2">
|
|
112
|
+
<label
|
|
113
|
+
class="text-sm font-medium leading-none"
|
|
114
|
+
for="yauth-confirm-password"
|
|
115
|
+
>
|
|
116
|
+
Confirm new password
|
|
117
|
+
</label>
|
|
118
|
+
<input
|
|
119
|
+
class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm"
|
|
120
|
+
id="yauth-confirm-password"
|
|
121
|
+
name="confirm_password"
|
|
122
|
+
type="password"
|
|
123
|
+
value={confirmPassword()}
|
|
124
|
+
onInput={(e) => setConfirmPassword(e.currentTarget.value)}
|
|
125
|
+
required
|
|
126
|
+
autocomplete="new-password"
|
|
127
|
+
disabled={loading()}
|
|
128
|
+
/>
|
|
129
|
+
</div>
|
|
130
|
+
|
|
131
|
+
<button
|
|
132
|
+
class="inline-flex h-9 w-full cursor-pointer items-center justify-center 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-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
|
|
133
|
+
type="submit"
|
|
134
|
+
disabled={loading()}
|
|
135
|
+
>
|
|
136
|
+
{loading() ? "Changing password..." : "Change password"}
|
|
137
|
+
</button>
|
|
138
|
+
</form>
|
|
139
|
+
);
|
|
140
|
+
};
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { type Component, createSignal, For } from "solid-js";
|
|
2
|
+
import { Show } from "solid-js/web";
|
|
3
|
+
|
|
4
|
+
export interface ConsentScreenProps {
|
|
5
|
+
clientName?: string;
|
|
6
|
+
clientId: string;
|
|
7
|
+
scopes?: string[];
|
|
8
|
+
redirectUri: string;
|
|
9
|
+
responseType: string;
|
|
10
|
+
codeChallenge: string;
|
|
11
|
+
codeChallengeMethod: string;
|
|
12
|
+
state?: string;
|
|
13
|
+
/** Called when user submits their consent decision */
|
|
14
|
+
onSubmit?: (approved: boolean) => void;
|
|
15
|
+
/** Called on error */
|
|
16
|
+
onError?: (error: Error) => void;
|
|
17
|
+
/** Auth API base URL (e.g. "/api/auth") */
|
|
18
|
+
authBaseUrl?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const ConsentScreen: Component<ConsentScreenProps> = (props) => {
|
|
22
|
+
const [loading, setLoading] = createSignal(false);
|
|
23
|
+
const [error, setError] = createSignal<string | null>(null);
|
|
24
|
+
|
|
25
|
+
const handleDecision = async (approved: boolean) => {
|
|
26
|
+
setError(null);
|
|
27
|
+
setLoading(true);
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const baseUrl = props.authBaseUrl ?? "/api/auth";
|
|
31
|
+
const response = await fetch(`${baseUrl}/authorize`, {
|
|
32
|
+
method: "POST",
|
|
33
|
+
headers: { "Content-Type": "application/json" },
|
|
34
|
+
credentials: "include",
|
|
35
|
+
body: JSON.stringify({
|
|
36
|
+
client_id: props.clientId,
|
|
37
|
+
redirect_uri: props.redirectUri,
|
|
38
|
+
response_type: props.responseType,
|
|
39
|
+
code_challenge: props.codeChallenge,
|
|
40
|
+
code_challenge_method: props.codeChallengeMethod,
|
|
41
|
+
scope: props.scopes?.join(" "),
|
|
42
|
+
state: props.state,
|
|
43
|
+
approved,
|
|
44
|
+
}),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
if (response.redirected) {
|
|
48
|
+
window.location.href = response.url;
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (!response.ok) {
|
|
53
|
+
const body = await response.json().catch(() => null);
|
|
54
|
+
throw new Error(
|
|
55
|
+
body?.error_description ?? body?.error ?? "Authorization failed",
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// If the response contains a redirect URL in the Location header
|
|
60
|
+
const location = response.headers.get("Location");
|
|
61
|
+
if (location) {
|
|
62
|
+
window.location.href = location;
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
props.onSubmit?.(approved);
|
|
67
|
+
} catch (err) {
|
|
68
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
69
|
+
setError(error.message);
|
|
70
|
+
props.onError?.(error);
|
|
71
|
+
} finally {
|
|
72
|
+
setLoading(false);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const displayName = () => props.clientName ?? props.clientId;
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<div class="mx-auto max-w-md space-y-6 p-6">
|
|
80
|
+
<div class="space-y-2 text-center">
|
|
81
|
+
<h2 class="text-2xl font-semibold tracking-tight">
|
|
82
|
+
Authorize {displayName()}
|
|
83
|
+
</h2>
|
|
84
|
+
<p class="text-sm text-muted-foreground">
|
|
85
|
+
<strong>{displayName()}</strong> is requesting access to your account.
|
|
86
|
+
</p>
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
<Show when={error()}>
|
|
90
|
+
<div class="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
|
91
|
+
{error()}
|
|
92
|
+
</div>
|
|
93
|
+
</Show>
|
|
94
|
+
|
|
95
|
+
<Show when={props.scopes && props.scopes.length > 0}>
|
|
96
|
+
<div class="rounded-md border p-4 space-y-3">
|
|
97
|
+
<p class="text-sm font-medium">
|
|
98
|
+
This application is requesting the following permissions:
|
|
99
|
+
</p>
|
|
100
|
+
<ul class="space-y-2">
|
|
101
|
+
<For each={props.scopes}>
|
|
102
|
+
{(scope) => (
|
|
103
|
+
<li class="flex items-center gap-2 text-sm">
|
|
104
|
+
<svg
|
|
105
|
+
class="h-4 w-4 text-primary"
|
|
106
|
+
fill="none"
|
|
107
|
+
stroke="currentColor"
|
|
108
|
+
viewBox="0 0 24 24"
|
|
109
|
+
aria-label="Checkmark"
|
|
110
|
+
role="img"
|
|
111
|
+
>
|
|
112
|
+
<path
|
|
113
|
+
stroke-linecap="round"
|
|
114
|
+
stroke-linejoin="round"
|
|
115
|
+
stroke-width="2"
|
|
116
|
+
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
117
|
+
/>
|
|
118
|
+
</svg>
|
|
119
|
+
<span>{scope}</span>
|
|
120
|
+
</li>
|
|
121
|
+
)}
|
|
122
|
+
</For>
|
|
123
|
+
</ul>
|
|
124
|
+
</div>
|
|
125
|
+
</Show>
|
|
126
|
+
|
|
127
|
+
<div class="flex gap-3">
|
|
128
|
+
<button
|
|
129
|
+
class="inline-flex h-9 flex-1 cursor-pointer items-center justify-center rounded-md border border-input 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-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
|
|
130
|
+
type="button"
|
|
131
|
+
disabled={loading()}
|
|
132
|
+
onClick={() => handleDecision(false)}
|
|
133
|
+
>
|
|
134
|
+
Deny
|
|
135
|
+
</button>
|
|
136
|
+
<button
|
|
137
|
+
class="inline-flex h-9 flex-1 cursor-pointer items-center justify-center 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-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
|
|
138
|
+
type="button"
|
|
139
|
+
disabled={loading()}
|
|
140
|
+
onClick={() => handleDecision(true)}
|
|
141
|
+
>
|
|
142
|
+
{loading() ? "Authorizing..." : "Authorize"}
|
|
143
|
+
</button>
|
|
144
|
+
</div>
|
|
145
|
+
|
|
146
|
+
<p class="text-center text-xs text-muted-foreground">
|
|
147
|
+
By authorizing, you allow this application to access your account with
|
|
148
|
+
the permissions listed above.
|
|
149
|
+
</p>
|
|
150
|
+
</div>
|
|
151
|
+
);
|
|
152
|
+
};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { type Component, createSignal } from "solid-js";
|
|
2
|
+
import { Show } from "solid-js/web";
|
|
3
|
+
import { useYAuth } from "../provider";
|
|
4
|
+
|
|
5
|
+
export interface ForgotPasswordFormProps {
|
|
6
|
+
onSuccess?: (message: string) => void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const ForgotPasswordForm: Component<ForgotPasswordFormProps> = (
|
|
10
|
+
props,
|
|
11
|
+
) => {
|
|
12
|
+
const { client } = useYAuth();
|
|
13
|
+
const [email, setEmail] = createSignal("");
|
|
14
|
+
const [error, setError] = createSignal<string | null>(null);
|
|
15
|
+
const [success, setSuccess] = createSignal<string | null>(null);
|
|
16
|
+
const [loading, setLoading] = createSignal(false);
|
|
17
|
+
|
|
18
|
+
const handleSubmit = async (e: SubmitEvent) => {
|
|
19
|
+
e.preventDefault();
|
|
20
|
+
setError(null);
|
|
21
|
+
setSuccess(null);
|
|
22
|
+
setLoading(true);
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const form = e.currentTarget as HTMLFormElement;
|
|
26
|
+
const formData = new FormData(form);
|
|
27
|
+
const result = await client.emailPassword.forgotPassword(
|
|
28
|
+
(formData.get("email") as string) || email(),
|
|
29
|
+
);
|
|
30
|
+
setSuccess(result.message);
|
|
31
|
+
props.onSuccess?.(result.message);
|
|
32
|
+
} catch (err) {
|
|
33
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
34
|
+
setError(error.message);
|
|
35
|
+
} finally {
|
|
36
|
+
setLoading(false);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<form class="space-y-4" onSubmit={handleSubmit}>
|
|
42
|
+
<Show when={error()}>
|
|
43
|
+
<div class="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
|
44
|
+
{error()}
|
|
45
|
+
</div>
|
|
46
|
+
</Show>
|
|
47
|
+
|
|
48
|
+
<Show when={success()}>
|
|
49
|
+
<div class="rounded-md bg-emerald-500/10 px-3 py-2 text-sm text-emerald-600 dark:text-emerald-400">
|
|
50
|
+
{success()}
|
|
51
|
+
</div>
|
|
52
|
+
</Show>
|
|
53
|
+
|
|
54
|
+
<div class="space-y-2">
|
|
55
|
+
<label
|
|
56
|
+
class="text-sm font-medium leading-none"
|
|
57
|
+
for="yauth-forgot-password-email"
|
|
58
|
+
>
|
|
59
|
+
Email
|
|
60
|
+
</label>
|
|
61
|
+
<input
|
|
62
|
+
class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm"
|
|
63
|
+
id="yauth-forgot-password-email"
|
|
64
|
+
name="email"
|
|
65
|
+
type="email"
|
|
66
|
+
value={email()}
|
|
67
|
+
onInput={(e) => setEmail(e.currentTarget.value)}
|
|
68
|
+
required
|
|
69
|
+
autocomplete="email"
|
|
70
|
+
disabled={loading()}
|
|
71
|
+
/>
|
|
72
|
+
</div>
|
|
73
|
+
|
|
74
|
+
<button
|
|
75
|
+
class="inline-flex h-9 w-full cursor-pointer items-center justify-center 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-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
|
|
76
|
+
type="submit"
|
|
77
|
+
disabled={loading()}
|
|
78
|
+
>
|
|
79
|
+
{loading() ? "Sending..." : "Send reset link"}
|
|
80
|
+
</button>
|
|
81
|
+
</form>
|
|
82
|
+
);
|
|
83
|
+
};
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import type { AuthUser } from "@yackey-labs/yauth-shared";
|
|
2
|
+
import { type Component, createSignal } from "solid-js";
|
|
3
|
+
import { Show } from "solid-js/web";
|
|
4
|
+
import { useYAuth } from "../provider";
|
|
5
|
+
import { PasskeyButton } from "./passkey-button";
|
|
6
|
+
|
|
7
|
+
export interface LoginFormProps {
|
|
8
|
+
onSuccess?: (user: AuthUser) => void;
|
|
9
|
+
onMfaRequired?: (pendingSessionId: string) => void;
|
|
10
|
+
onError?: (error: Error) => void;
|
|
11
|
+
showPasskey?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const LoginForm: Component<LoginFormProps> = (props) => {
|
|
15
|
+
const { client, refetch } = useYAuth();
|
|
16
|
+
const [email, setEmail] = createSignal("");
|
|
17
|
+
const [password, setPassword] = createSignal("");
|
|
18
|
+
const [error, setError] = createSignal<string | null>(null);
|
|
19
|
+
const [loading, setLoading] = createSignal(false);
|
|
20
|
+
|
|
21
|
+
const handleSubmit = async (e: SubmitEvent) => {
|
|
22
|
+
e.preventDefault();
|
|
23
|
+
setError(null);
|
|
24
|
+
setLoading(true);
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const form = e.currentTarget as HTMLFormElement;
|
|
28
|
+
const formData = new FormData(form);
|
|
29
|
+
const result = await client.emailPassword.login({
|
|
30
|
+
email: (formData.get("email") as string) || email(),
|
|
31
|
+
password: (formData.get("password") as string) || password(),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
if ("mfa_required" in result && result.mfa_required) {
|
|
35
|
+
props.onMfaRequired?.(result.pending_session_id);
|
|
36
|
+
} else {
|
|
37
|
+
// Session cookie is set — refetch and await so reactive store updates
|
|
38
|
+
const user = await refetch();
|
|
39
|
+
props.onSuccess?.(user!);
|
|
40
|
+
}
|
|
41
|
+
} catch (err) {
|
|
42
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
43
|
+
setError(error.message);
|
|
44
|
+
props.onError?.(error);
|
|
45
|
+
} finally {
|
|
46
|
+
setLoading(false);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<form class="space-y-6" on:submit={handleSubmit}>
|
|
52
|
+
<Show when={error()}>
|
|
53
|
+
<div class="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
|
54
|
+
{error()}
|
|
55
|
+
</div>
|
|
56
|
+
</Show>
|
|
57
|
+
|
|
58
|
+
<div class="space-y-2">
|
|
59
|
+
<label class="text-sm font-medium leading-none" for="yauth-login-email">
|
|
60
|
+
Email
|
|
61
|
+
</label>
|
|
62
|
+
<input
|
|
63
|
+
class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm"
|
|
64
|
+
id="yauth-login-email"
|
|
65
|
+
name="email"
|
|
66
|
+
type="email"
|
|
67
|
+
value={email()}
|
|
68
|
+
on:input={(e) => setEmail(e.currentTarget.value)}
|
|
69
|
+
required
|
|
70
|
+
autocomplete="email"
|
|
71
|
+
disabled={loading()}
|
|
72
|
+
/>
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
<div class="space-y-2">
|
|
76
|
+
<label
|
|
77
|
+
class="text-sm font-medium leading-none"
|
|
78
|
+
for="yauth-login-password"
|
|
79
|
+
>
|
|
80
|
+
Password
|
|
81
|
+
</label>
|
|
82
|
+
<input
|
|
83
|
+
class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm"
|
|
84
|
+
id="yauth-login-password"
|
|
85
|
+
name="password"
|
|
86
|
+
type="password"
|
|
87
|
+
value={password()}
|
|
88
|
+
on:input={(e) => setPassword(e.currentTarget.value)}
|
|
89
|
+
required
|
|
90
|
+
autocomplete="current-password"
|
|
91
|
+
disabled={loading()}
|
|
92
|
+
/>
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
<button
|
|
96
|
+
class="inline-flex h-9 w-full cursor-pointer items-center justify-center 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-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
|
|
97
|
+
type="submit"
|
|
98
|
+
disabled={loading()}
|
|
99
|
+
>
|
|
100
|
+
{loading() ? "Signing in..." : "Sign in"}
|
|
101
|
+
</button>
|
|
102
|
+
|
|
103
|
+
<Show when={props.showPasskey}>
|
|
104
|
+
<div class="relative">
|
|
105
|
+
<div
|
|
106
|
+
class="absolute inset-0 flex items-center"
|
|
107
|
+
style="pointer-events:none"
|
|
108
|
+
>
|
|
109
|
+
<span class="w-full border-t" />
|
|
110
|
+
</div>
|
|
111
|
+
<div class="relative flex justify-center text-xs uppercase">
|
|
112
|
+
<span class="bg-background px-2 text-muted-foreground">or</span>
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
|
|
116
|
+
<PasskeyButton
|
|
117
|
+
mode="login"
|
|
118
|
+
email={email()}
|
|
119
|
+
onSuccess={(user) => {
|
|
120
|
+
void refetch();
|
|
121
|
+
props.onSuccess?.(user);
|
|
122
|
+
}}
|
|
123
|
+
onError={props.onError}
|
|
124
|
+
/>
|
|
125
|
+
</Show>
|
|
126
|
+
</form>
|
|
127
|
+
);
|
|
128
|
+
};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { type Component, createSignal } from "solid-js";
|
|
2
|
+
import { Show } from "solid-js/web";
|
|
3
|
+
import { useYAuth } from "../provider";
|
|
4
|
+
|
|
5
|
+
export interface MagicLinkFormProps {
|
|
6
|
+
onSuccess?: (message: string) => void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const MagicLinkForm: Component<MagicLinkFormProps> = (props) => {
|
|
10
|
+
const { client } = useYAuth();
|
|
11
|
+
const [email, setEmail] = createSignal("");
|
|
12
|
+
const [error, setError] = createSignal<string | null>(null);
|
|
13
|
+
const [success, setSuccess] = createSignal<string | null>(null);
|
|
14
|
+
const [loading, setLoading] = createSignal(false);
|
|
15
|
+
|
|
16
|
+
const handleSubmit = async (e: SubmitEvent) => {
|
|
17
|
+
e.preventDefault();
|
|
18
|
+
setError(null);
|
|
19
|
+
setSuccess(null);
|
|
20
|
+
setLoading(true);
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const form = e.currentTarget as HTMLFormElement;
|
|
24
|
+
const formData = new FormData(form);
|
|
25
|
+
const result = await client.magicLink.send(
|
|
26
|
+
(formData.get("email") as string) || email(),
|
|
27
|
+
);
|
|
28
|
+
setSuccess(result.message);
|
|
29
|
+
props.onSuccess?.(result.message);
|
|
30
|
+
} catch (err) {
|
|
31
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
32
|
+
setError(error.message);
|
|
33
|
+
} finally {
|
|
34
|
+
setLoading(false);
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<form class="space-y-4" onSubmit={handleSubmit}>
|
|
40
|
+
<Show when={error()}>
|
|
41
|
+
<div class="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
|
42
|
+
{error()}
|
|
43
|
+
</div>
|
|
44
|
+
</Show>
|
|
45
|
+
|
|
46
|
+
<Show when={success()}>
|
|
47
|
+
<div class="rounded-md bg-emerald-500/10 px-3 py-2 text-sm text-emerald-600 dark:text-emerald-400">
|
|
48
|
+
{success()}
|
|
49
|
+
</div>
|
|
50
|
+
</Show>
|
|
51
|
+
|
|
52
|
+
<Show when={!success()}>
|
|
53
|
+
<div class="space-y-2">
|
|
54
|
+
<label
|
|
55
|
+
class="text-sm font-medium leading-none"
|
|
56
|
+
for="yauth-magic-link-email"
|
|
57
|
+
>
|
|
58
|
+
Email
|
|
59
|
+
</label>
|
|
60
|
+
<input
|
|
61
|
+
class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm"
|
|
62
|
+
id="yauth-magic-link-email"
|
|
63
|
+
name="email"
|
|
64
|
+
type="email"
|
|
65
|
+
value={email()}
|
|
66
|
+
onInput={(e) => setEmail(e.currentTarget.value)}
|
|
67
|
+
required
|
|
68
|
+
autocomplete="email"
|
|
69
|
+
disabled={loading()}
|
|
70
|
+
placeholder="you@example.com"
|
|
71
|
+
/>
|
|
72
|
+
</div>
|
|
73
|
+
|
|
74
|
+
<button
|
|
75
|
+
class="inline-flex h-9 w-full cursor-pointer items-center justify-center 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-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
|
|
76
|
+
type="submit"
|
|
77
|
+
disabled={loading()}
|
|
78
|
+
>
|
|
79
|
+
{loading() ? "Sending..." : "Send magic link"}
|
|
80
|
+
</button>
|
|
81
|
+
</Show>
|
|
82
|
+
</form>
|
|
83
|
+
);
|
|
84
|
+
};
|