@exegia/corpora-ui 0.5.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/README.md +21 -0
- package/dist-lib/components/blocks/auth/auth-shell.d.ts +56 -0
- package/dist-lib/components/blocks/auth/code-auth-block.d.ts +22 -0
- package/dist-lib/components/blocks/auth/forgot-password-block.d.ts +15 -0
- package/dist-lib/components/blocks/auth/login-block.d.ts +25 -0
- package/dist-lib/components/blocks/auth/signup-block.d.ts +26 -0
- package/dist-lib/components/composed/password-input.d.ts +30 -0
- package/dist-lib/components/composed/social-providers.d.ts +22 -0
- package/dist-lib/components/docs/code-block.d.ts +4 -0
- package/dist-lib/components/docs/component-preview.d.ts +10 -0
- package/dist-lib/components/docs/demo-controls.d.ts +28 -0
- package/dist-lib/components/docs/props-table.d.ts +4 -0
- package/dist-lib/components/theme-provider.d.ts +15 -0
- package/dist-lib/components/ui/button.d.ts +28 -0
- package/dist-lib/components/ui/card.d.ts +16 -0
- package/dist-lib/components/ui/checkbox.d.ts +7 -0
- package/dist-lib/components/ui/field.d.ts +10 -0
- package/dist-lib/components/ui/frame.d.ts +7 -0
- package/dist-lib/components/ui/glasscn/glass-button.d.ts +6 -0
- package/dist-lib/components/ui/glasscn/liquid-glass.d.ts +31 -0
- package/dist-lib/components/ui/input-group.d.ts +13 -0
- package/dist-lib/components/ui/input.d.ts +9 -0
- package/dist-lib/components/ui/label.d.ts +11 -0
- package/dist-lib/components/ui/otp-field.d.ts +11 -0
- package/dist-lib/components/ui/separator.d.ts +4 -0
- package/dist-lib/components/ui/spinner.d.ts +3 -0
- package/dist-lib/components/ui/textarea.d.ts +8 -0
- package/dist-lib/index.d.ts +29 -0
- package/dist-lib/index.js +1617 -0
- package/dist-lib/index.js.map +1 -0
- package/dist-lib/lib/auth-accent.d.ts +33 -0
- package/dist-lib/lib/glass-variants.d.ts +6 -0
- package/dist-lib/lib/sound.d.ts +13 -0
- package/dist-lib/lib/utils.d.ts +2 -0
- package/dist-lib/lib/utils.test.d.ts +1 -0
- package/package.json +80 -0
- package/src/components/blocks/.gitkeep +0 -0
- package/src/components/blocks/auth/auth-shell.tsx +266 -0
- package/src/components/blocks/auth/code-auth-block.tsx +190 -0
- package/src/components/blocks/auth/forgot-password-block.tsx +123 -0
- package/src/components/blocks/auth/login-block.tsx +239 -0
- package/src/components/blocks/auth/signup-block.tsx +260 -0
- package/src/components/composed/.gitkeep +0 -0
- package/src/components/composed/password-input.tsx +179 -0
- package/src/components/composed/social-providers.tsx +89 -0
- package/src/components/docs/code-block.tsx +8 -0
- package/src/components/docs/component-preview.tsx +28 -0
- package/src/components/docs/demo-controls.tsx +118 -0
- package/src/components/docs/props-table.tsx +35 -0
- package/src/components/theme-provider.tsx +230 -0
- package/src/components/ui/button.tsx +199 -0
- package/src/components/ui/card.tsx +253 -0
- package/src/components/ui/checkbox.tsx +83 -0
- package/src/components/ui/field.tsx +89 -0
- package/src/components/ui/frame.tsx +87 -0
- package/src/components/ui/glasscn/glass-button.tsx +25 -0
- package/src/components/ui/glasscn/liquid-glass.tsx +276 -0
- package/src/components/ui/input-group.tsx +105 -0
- package/src/components/ui/input.tsx +70 -0
- package/src/components/ui/label.tsx +36 -0
- package/src/components/ui/otp-field.tsx +88 -0
- package/src/components/ui/separator.tsx +23 -0
- package/src/components/ui/spinner.tsx +17 -0
- package/src/components/ui/textarea.tsx +58 -0
- package/src/index.css +171 -0
- package/src/index.ts +37 -0
- package/src/lib/auth-accent.ts +49 -0
- package/src/lib/glass-variants.ts +57 -0
- package/src/lib/sound.ts +39 -0
- package/src/lib/utils.test.ts +22 -0
- package/src/lib/utils.ts +6 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
|
|
5
|
+
import { PasswordInput } from "@/components/composed/password-input";
|
|
6
|
+
import {
|
|
7
|
+
SocialProviders,
|
|
8
|
+
type SocialProvider,
|
|
9
|
+
} from "@/components/composed/social-providers";
|
|
10
|
+
import { Button } from "@/components/ui/button";
|
|
11
|
+
import { Checkbox } from "@/components/ui/checkbox";
|
|
12
|
+
import { Field, FieldLabel } from "@/components/ui/field";
|
|
13
|
+
import { Input } from "@/components/ui/input";
|
|
14
|
+
import { Label } from "@/components/ui/label";
|
|
15
|
+
import { type AuthAccent, authAccentActionStyles } from "@/lib/auth-accent";
|
|
16
|
+
import { cn } from "@/lib/utils";
|
|
17
|
+
import {
|
|
18
|
+
AuthCard,
|
|
19
|
+
AuthError,
|
|
20
|
+
AuthSeparator,
|
|
21
|
+
AuthSuccess,
|
|
22
|
+
MorphStep,
|
|
23
|
+
Reveal,
|
|
24
|
+
type AuthStatus,
|
|
25
|
+
} from "./auth-shell";
|
|
26
|
+
|
|
27
|
+
export interface LoginBlockProps {
|
|
28
|
+
title?: string;
|
|
29
|
+
description?: string;
|
|
30
|
+
/** Brand mark rendered above the title. Omit for no logo row at all. */
|
|
31
|
+
logo?: React.ReactNode;
|
|
32
|
+
/** Brand accent for the primary action. Omit to keep the default primary. */
|
|
33
|
+
accent?: AuthAccent;
|
|
34
|
+
/** Social providers to offer; empty array hides the social section. */
|
|
35
|
+
providers?: SocialProvider[];
|
|
36
|
+
showRememberMe?: boolean;
|
|
37
|
+
showForgotPassword?: boolean;
|
|
38
|
+
/** Reject (or throw) to show the error state with the error's message. */
|
|
39
|
+
onSubmit?: (data: {
|
|
40
|
+
email: string;
|
|
41
|
+
password: string;
|
|
42
|
+
remember: boolean;
|
|
43
|
+
}) => Promise<void> | void;
|
|
44
|
+
onProviderSelect?: (provider: SocialProvider) => Promise<void> | void;
|
|
45
|
+
onForgotPassword?: () => void;
|
|
46
|
+
onSignup?: () => void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function LoginBlock({
|
|
50
|
+
title = "Login to your account",
|
|
51
|
+
description = "Enter your email and password to login",
|
|
52
|
+
logo,
|
|
53
|
+
accent,
|
|
54
|
+
providers = ["google", "apple", "github"],
|
|
55
|
+
showRememberMe = true,
|
|
56
|
+
showForgotPassword = true,
|
|
57
|
+
onSubmit,
|
|
58
|
+
onProviderSelect,
|
|
59
|
+
onForgotPassword,
|
|
60
|
+
onSignup,
|
|
61
|
+
}: LoginBlockProps) {
|
|
62
|
+
const emailId = React.useId();
|
|
63
|
+
const rememberId = React.useId();
|
|
64
|
+
const [status, setStatus] = React.useState<AuthStatus>("idle");
|
|
65
|
+
const [error, setError] = React.useState<string | null>(null);
|
|
66
|
+
const [loadingProvider, setLoadingProvider] =
|
|
67
|
+
React.useState<SocialProvider | null>(null);
|
|
68
|
+
const [remember, setRemember] = React.useState(false);
|
|
69
|
+
const [email, setEmail] = React.useState("");
|
|
70
|
+
const [emailValid, setEmailValid] = React.useState(false);
|
|
71
|
+
const [password, setPassword] = React.useState("");
|
|
72
|
+
|
|
73
|
+
const busy = status === "loading" || loadingProvider !== null;
|
|
74
|
+
|
|
75
|
+
// Progressive disclosure: each step unlocks the next. The password field is
|
|
76
|
+
// gated on the email input's own constraint validation (required + type
|
|
77
|
+
// email), the submit button on the password being non-empty — a login form
|
|
78
|
+
// verifies a credential rather than enforcing policy, so strength rules here
|
|
79
|
+
// would lock out anyone whose password predates them. The submit gate
|
|
80
|
+
// re-checks emailValid so a stale password (typed, then the email was edited
|
|
81
|
+
// back into an invalid state) can't leave the button on its own.
|
|
82
|
+
const passwordValid = password.trim().length > 0;
|
|
83
|
+
const canSubmit = emailValid && passwordValid;
|
|
84
|
+
|
|
85
|
+
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
|
86
|
+
event.preventDefault();
|
|
87
|
+
// Enter in any field submits the form even while the button is hidden.
|
|
88
|
+
if (!canSubmit) return;
|
|
89
|
+
const form = new FormData(event.currentTarget);
|
|
90
|
+
setError(null);
|
|
91
|
+
setStatus("loading");
|
|
92
|
+
try {
|
|
93
|
+
await onSubmit?.({
|
|
94
|
+
email: String(form.get("email") ?? ""),
|
|
95
|
+
password: String(form.get("password") ?? ""),
|
|
96
|
+
remember,
|
|
97
|
+
});
|
|
98
|
+
setStatus("success");
|
|
99
|
+
} catch (cause) {
|
|
100
|
+
setStatus("idle");
|
|
101
|
+
setError(cause instanceof Error ? cause.message : "Unable to login.");
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function handleProvider(provider: SocialProvider) {
|
|
106
|
+
setError(null);
|
|
107
|
+
setLoadingProvider(provider);
|
|
108
|
+
try {
|
|
109
|
+
await onProviderSelect?.(provider);
|
|
110
|
+
setStatus("success");
|
|
111
|
+
} catch (cause) {
|
|
112
|
+
setError(
|
|
113
|
+
cause instanceof Error ? cause.message : "Unable to login.",
|
|
114
|
+
);
|
|
115
|
+
} finally {
|
|
116
|
+
setLoadingProvider(null);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<AuthCard
|
|
122
|
+
title={title}
|
|
123
|
+
description={description}
|
|
124
|
+
logo={logo}
|
|
125
|
+
accent={accent}
|
|
126
|
+
footer={
|
|
127
|
+
status !== "success" && (
|
|
128
|
+
<>
|
|
129
|
+
Don't have an account?{" "}
|
|
130
|
+
<Button variant="link" onClick={onSignup}>
|
|
131
|
+
Sign up
|
|
132
|
+
</Button>
|
|
133
|
+
</>
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
>
|
|
137
|
+
<MorphStep step={status === "success" ? "success" : "form"}>
|
|
138
|
+
{status === "success" ? (
|
|
139
|
+
<AuthSuccess
|
|
140
|
+
title="Welcome back"
|
|
141
|
+
description="You are now logged in."
|
|
142
|
+
/>
|
|
143
|
+
) : (
|
|
144
|
+
<div className="flex flex-col gap-4">
|
|
145
|
+
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
|
146
|
+
<Field name="email">
|
|
147
|
+
<FieldLabel htmlFor={emailId}>Email</FieldLabel>
|
|
148
|
+
<Input
|
|
149
|
+
id={emailId}
|
|
150
|
+
aria-label="Email"
|
|
151
|
+
name="email"
|
|
152
|
+
type="email"
|
|
153
|
+
autoComplete="email"
|
|
154
|
+
placeholder="you@example.com"
|
|
155
|
+
required
|
|
156
|
+
disabled={busy}
|
|
157
|
+
value={email}
|
|
158
|
+
onChange={(event) => {
|
|
159
|
+
setEmail(event.currentTarget.value);
|
|
160
|
+
setEmailValid(event.currentTarget.validity.valid);
|
|
161
|
+
}}
|
|
162
|
+
/>
|
|
163
|
+
</Field>
|
|
164
|
+
<Reveal show={emailValid}>
|
|
165
|
+
<Field name="password">
|
|
166
|
+
<FieldLabel htmlFor="password">Password</FieldLabel>
|
|
167
|
+
<PasswordInput
|
|
168
|
+
id="password"
|
|
169
|
+
name="password"
|
|
170
|
+
autoComplete="current-password"
|
|
171
|
+
required
|
|
172
|
+
disabled={busy}
|
|
173
|
+
value={password}
|
|
174
|
+
onChange={(event) => setPassword(event.target.value)}
|
|
175
|
+
/>
|
|
176
|
+
{showForgotPassword && (
|
|
177
|
+
// self-end: Field root is items-start, so this aligns the
|
|
178
|
+
// link with the input's trailing edge. sm:text-xs is
|
|
179
|
+
// required — the cva's sm:text-sm survives tailwind-merge
|
|
180
|
+
// and would otherwise win at the breakpoint.
|
|
181
|
+
<Button
|
|
182
|
+
variant="link"
|
|
183
|
+
type="button"
|
|
184
|
+
className="self-end font-normal text-muted-foreground text-xs hover:text-foreground sm:text-xs"
|
|
185
|
+
onClick={onForgotPassword}
|
|
186
|
+
>
|
|
187
|
+
Forgot password?
|
|
188
|
+
</Button>
|
|
189
|
+
)}
|
|
190
|
+
</Field>
|
|
191
|
+
</Reveal>
|
|
192
|
+
{showRememberMe && (
|
|
193
|
+
<div className="flex items-center gap-2">
|
|
194
|
+
<Checkbox
|
|
195
|
+
id={rememberId}
|
|
196
|
+
checked={remember}
|
|
197
|
+
onCheckedChange={(checked) => setRemember(checked)}
|
|
198
|
+
disabled={busy}
|
|
199
|
+
/>
|
|
200
|
+
<Label htmlFor={rememberId} className="font-normal">
|
|
201
|
+
Remember me
|
|
202
|
+
</Label>
|
|
203
|
+
</div>
|
|
204
|
+
)}
|
|
205
|
+
<AuthError message={error} />
|
|
206
|
+
<Reveal show={canSubmit}>
|
|
207
|
+
<Button
|
|
208
|
+
className={cn("w-full", accent && authAccentActionStyles)}
|
|
209
|
+
type="submit"
|
|
210
|
+
loading={status === "loading"}
|
|
211
|
+
disabled={busy}
|
|
212
|
+
>
|
|
213
|
+
Login
|
|
214
|
+
</Button>
|
|
215
|
+
</Reveal>
|
|
216
|
+
</form>
|
|
217
|
+
{providers.length > 0 && (
|
|
218
|
+
// The social providers retire once the email path is filled in
|
|
219
|
+
// and valid — the user has committed to a path, so the
|
|
220
|
+
// alternatives are just noise. They come back if a field is
|
|
221
|
+
// invalidated again. gap-4 on the wrapper reproduces the spacing
|
|
222
|
+
// the two children had as direct flex items of the column above.
|
|
223
|
+
<Reveal show={!canSubmit} className="flex flex-col gap-4">
|
|
224
|
+
<AuthSeparator label="Or continue with" />
|
|
225
|
+
<SocialProviders
|
|
226
|
+
providers={providers}
|
|
227
|
+
action="login"
|
|
228
|
+
loadingProvider={loadingProvider}
|
|
229
|
+
disabled={status === "loading"}
|
|
230
|
+
onSelect={handleProvider}
|
|
231
|
+
/>
|
|
232
|
+
</Reveal>
|
|
233
|
+
)}
|
|
234
|
+
</div>
|
|
235
|
+
)}
|
|
236
|
+
</MorphStep>
|
|
237
|
+
</AuthCard>
|
|
238
|
+
);
|
|
239
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
PasswordInput,
|
|
7
|
+
getPasswordStrength,
|
|
8
|
+
passwordRequirements,
|
|
9
|
+
} from "@/components/composed/password-input";
|
|
10
|
+
import {
|
|
11
|
+
SocialProviders,
|
|
12
|
+
type SocialProvider,
|
|
13
|
+
} from "@/components/composed/social-providers";
|
|
14
|
+
import { Button } from "@/components/ui/button";
|
|
15
|
+
import { Checkbox } from "@/components/ui/checkbox";
|
|
16
|
+
import { Field, FieldLabel } from "@/components/ui/field";
|
|
17
|
+
import { Input } from "@/components/ui/input";
|
|
18
|
+
import { Label } from "@/components/ui/label";
|
|
19
|
+
import { type AuthAccent, authAccentActionStyles } from "@/lib/auth-accent";
|
|
20
|
+
import { cn } from "@/lib/utils";
|
|
21
|
+
import {
|
|
22
|
+
AuthCard,
|
|
23
|
+
AuthError,
|
|
24
|
+
AuthSeparator,
|
|
25
|
+
AuthSuccess,
|
|
26
|
+
MorphStep,
|
|
27
|
+
Reveal,
|
|
28
|
+
type AuthStatus,
|
|
29
|
+
} from "./auth-shell";
|
|
30
|
+
|
|
31
|
+
export interface SignupBlockProps {
|
|
32
|
+
title?: string;
|
|
33
|
+
description?: string;
|
|
34
|
+
/** Brand mark rendered above the title. Omit for no logo row at all. */
|
|
35
|
+
logo?: React.ReactNode;
|
|
36
|
+
/** Brand accent for the primary action. Omit to keep the default primary. */
|
|
37
|
+
accent?: AuthAccent;
|
|
38
|
+
providers?: SocialProvider[];
|
|
39
|
+
showNameField?: boolean;
|
|
40
|
+
/** Require the terms checkbox before submitting. */
|
|
41
|
+
showTerms?: boolean;
|
|
42
|
+
/** Block submission until every password requirement is met. */
|
|
43
|
+
enforceStrongPassword?: boolean;
|
|
44
|
+
onSubmit?: (data: {
|
|
45
|
+
name: string;
|
|
46
|
+
email: string;
|
|
47
|
+
password: string;
|
|
48
|
+
}) => Promise<void> | void;
|
|
49
|
+
onProviderSelect?: (provider: SocialProvider) => Promise<void> | void;
|
|
50
|
+
onLogin?: () => void;
|
|
51
|
+
onTerms?: () => void;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function SignupBlock({
|
|
55
|
+
title = "Create your account",
|
|
56
|
+
description = "Start exploring manuscripts in minutes",
|
|
57
|
+
logo,
|
|
58
|
+
accent,
|
|
59
|
+
providers = ["google", "apple", "github"],
|
|
60
|
+
showNameField = true,
|
|
61
|
+
showTerms = true,
|
|
62
|
+
enforceStrongPassword = true,
|
|
63
|
+
onSubmit,
|
|
64
|
+
onProviderSelect,
|
|
65
|
+
onLogin,
|
|
66
|
+
onTerms,
|
|
67
|
+
}: SignupBlockProps) {
|
|
68
|
+
const nameId = React.useId();
|
|
69
|
+
const emailId = React.useId();
|
|
70
|
+
const termsId = React.useId();
|
|
71
|
+
const [status, setStatus] = React.useState<AuthStatus>("idle");
|
|
72
|
+
const [error, setError] = React.useState<string | null>(null);
|
|
73
|
+
const [loadingProvider, setLoadingProvider] =
|
|
74
|
+
React.useState<SocialProvider | null>(null);
|
|
75
|
+
const [password, setPassword] = React.useState("");
|
|
76
|
+
const [terms, setTerms] = React.useState(false);
|
|
77
|
+
// Tracked from each input's own constraint validation rather than a regex.
|
|
78
|
+
// The values stay uncontrolled — only validity is needed, and the form reads
|
|
79
|
+
// values from FormData on submit.
|
|
80
|
+
const [emailValid, setEmailValid] = React.useState(false);
|
|
81
|
+
const [nameValid, setNameValid] = React.useState(false);
|
|
82
|
+
|
|
83
|
+
const busy = status === "loading" || loadingProvider !== null;
|
|
84
|
+
const strongEnough =
|
|
85
|
+
!enforceStrongPassword ||
|
|
86
|
+
getPasswordStrength(password) === passwordRequirements.length;
|
|
87
|
+
|
|
88
|
+
// The social providers retire once the email path is filled in and valid —
|
|
89
|
+
// the user has committed to a path, so the alternatives are just noise. It
|
|
90
|
+
// comes back if they invalidate a field again.
|
|
91
|
+
const passwordValid = password.trim().length > 0 && strongEnough;
|
|
92
|
+
const emailPathComplete =
|
|
93
|
+
(!showNameField || nameValid) && emailValid && passwordValid;
|
|
94
|
+
|
|
95
|
+
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
|
96
|
+
event.preventDefault();
|
|
97
|
+
setError(null);
|
|
98
|
+
if (!strongEnough) {
|
|
99
|
+
setError("Please meet all password requirements.");
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (showTerms && !terms) {
|
|
103
|
+
setError("Please accept the terms to continue.");
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const form = new FormData(event.currentTarget);
|
|
107
|
+
setStatus("loading");
|
|
108
|
+
try {
|
|
109
|
+
await onSubmit?.({
|
|
110
|
+
name: String(form.get("name") ?? ""),
|
|
111
|
+
email: String(form.get("email") ?? ""),
|
|
112
|
+
password,
|
|
113
|
+
});
|
|
114
|
+
setStatus("success");
|
|
115
|
+
} catch (cause) {
|
|
116
|
+
setStatus("idle");
|
|
117
|
+
setError(
|
|
118
|
+
cause instanceof Error ? cause.message : "Unable to create account.",
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function handleProvider(provider: SocialProvider) {
|
|
124
|
+
setError(null);
|
|
125
|
+
setLoadingProvider(provider);
|
|
126
|
+
try {
|
|
127
|
+
await onProviderSelect?.(provider);
|
|
128
|
+
setStatus("success");
|
|
129
|
+
} catch (cause) {
|
|
130
|
+
setError(
|
|
131
|
+
cause instanceof Error ? cause.message : "Unable to create account.",
|
|
132
|
+
);
|
|
133
|
+
} finally {
|
|
134
|
+
setLoadingProvider(null);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return (
|
|
139
|
+
<AuthCard
|
|
140
|
+
title={title}
|
|
141
|
+
description={description}
|
|
142
|
+
logo={logo}
|
|
143
|
+
accent={accent}
|
|
144
|
+
footer={
|
|
145
|
+
status !== "success" && (
|
|
146
|
+
<>
|
|
147
|
+
Already have an account?{" "}
|
|
148
|
+
<Button variant="link" onClick={onLogin}>
|
|
149
|
+
Login
|
|
150
|
+
</Button>
|
|
151
|
+
</>
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
>
|
|
155
|
+
<MorphStep step={status === "success" ? "success" : "form"}>
|
|
156
|
+
{status === "success" ? (
|
|
157
|
+
<AuthSuccess
|
|
158
|
+
title="Account created"
|
|
159
|
+
description="Check your inbox to confirm your email address."
|
|
160
|
+
/>
|
|
161
|
+
) : (
|
|
162
|
+
<div className="flex flex-col gap-4">
|
|
163
|
+
{providers.length > 0 && (
|
|
164
|
+
// gap-4 on the wrapper reproduces the spacing the two children
|
|
165
|
+
// had as direct flex items of the column above.
|
|
166
|
+
<Reveal show={!emailPathComplete} className="flex flex-col gap-4">
|
|
167
|
+
<SocialProviders
|
|
168
|
+
providers={providers}
|
|
169
|
+
action="signup"
|
|
170
|
+
loadingProvider={loadingProvider}
|
|
171
|
+
disabled={status === "loading"}
|
|
172
|
+
onSelect={handleProvider}
|
|
173
|
+
/>
|
|
174
|
+
<AuthSeparator label="Or with email" />
|
|
175
|
+
</Reveal>
|
|
176
|
+
)}
|
|
177
|
+
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
|
178
|
+
{showNameField && (
|
|
179
|
+
<Field name="name">
|
|
180
|
+
<FieldLabel htmlFor={nameId}>Name</FieldLabel>
|
|
181
|
+
<Input
|
|
182
|
+
aria-label="Name"
|
|
183
|
+
id={nameId}
|
|
184
|
+
name="name"
|
|
185
|
+
autoComplete="name"
|
|
186
|
+
placeholder="Your name"
|
|
187
|
+
required
|
|
188
|
+
disabled={busy}
|
|
189
|
+
onChange={(event) =>
|
|
190
|
+
setNameValid(event.currentTarget.validity.valid)
|
|
191
|
+
}
|
|
192
|
+
/>
|
|
193
|
+
</Field>
|
|
194
|
+
)}
|
|
195
|
+
<Field name="email">
|
|
196
|
+
<FieldLabel htmlFor={emailId}>Email</FieldLabel>
|
|
197
|
+
<Input
|
|
198
|
+
id={emailId}
|
|
199
|
+
name="email"
|
|
200
|
+
type="email"
|
|
201
|
+
autoComplete="email"
|
|
202
|
+
placeholder="you@example.com"
|
|
203
|
+
required
|
|
204
|
+
disabled={busy}
|
|
205
|
+
onChange={(event) =>
|
|
206
|
+
setEmailValid(event.currentTarget.validity.valid)
|
|
207
|
+
}
|
|
208
|
+
/>
|
|
209
|
+
</Field>
|
|
210
|
+
<Reveal show={emailValid}>
|
|
211
|
+
<Field name="password">
|
|
212
|
+
<FieldLabel>Password</FieldLabel>
|
|
213
|
+
<PasswordInput
|
|
214
|
+
name="password"
|
|
215
|
+
autoComplete="new-password"
|
|
216
|
+
placeholder="Create a password"
|
|
217
|
+
showStrength
|
|
218
|
+
required
|
|
219
|
+
disabled={busy}
|
|
220
|
+
value={password}
|
|
221
|
+
onChange={(event) => setPassword(event.target.value)}
|
|
222
|
+
/>
|
|
223
|
+
</Field>
|
|
224
|
+
</Reveal>
|
|
225
|
+
{showTerms && (
|
|
226
|
+
<div className="flex items-center gap-2">
|
|
227
|
+
<Checkbox
|
|
228
|
+
id={termsId}
|
|
229
|
+
checked={terms}
|
|
230
|
+
onCheckedChange={(checked) => setTerms(checked === true)}
|
|
231
|
+
disabled={busy}
|
|
232
|
+
/>
|
|
233
|
+
<Label htmlFor={termsId} className="font-normal">
|
|
234
|
+
I agree to the{" "}
|
|
235
|
+
<Button
|
|
236
|
+
variant="link"
|
|
237
|
+
type="button"
|
|
238
|
+
onClick={onTerms}
|
|
239
|
+
>
|
|
240
|
+
terms
|
|
241
|
+
</Button>
|
|
242
|
+
</Label>
|
|
243
|
+
</div>
|
|
244
|
+
)}
|
|
245
|
+
<AuthError message={error} />
|
|
246
|
+
<Button
|
|
247
|
+
className={cn("w-full", accent && authAccentActionStyles)}
|
|
248
|
+
type="submit"
|
|
249
|
+
loading={status === "loading"}
|
|
250
|
+
disabled={busy}
|
|
251
|
+
>
|
|
252
|
+
Create account
|
|
253
|
+
</Button>
|
|
254
|
+
</form>
|
|
255
|
+
</div>
|
|
256
|
+
)}
|
|
257
|
+
</MorphStep>
|
|
258
|
+
</AuthCard>
|
|
259
|
+
);
|
|
260
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { CheckIcon, EyeIcon, EyeOffIcon, XIcon } from "lucide-react";
|
|
4
|
+
import { AnimatePresence, motion } from "motion/react";
|
|
5
|
+
import * as React from "react";
|
|
6
|
+
|
|
7
|
+
import { Button } from "@/components/ui/button";
|
|
8
|
+
import {
|
|
9
|
+
InputGroup,
|
|
10
|
+
InputGroupAddon,
|
|
11
|
+
InputGroupInput,
|
|
12
|
+
} from "@/components/ui/input-group";
|
|
13
|
+
import { playCue } from "@/lib/sound";
|
|
14
|
+
import { cn } from "@/lib/utils";
|
|
15
|
+
|
|
16
|
+
// transitions.dev motion scale
|
|
17
|
+
const EASE = [0.22, 1, 0.36, 1] as const;
|
|
18
|
+
|
|
19
|
+
export const passwordRequirements = [
|
|
20
|
+
{ regex: /.{8,}/, text: "At least 8 characters" },
|
|
21
|
+
{ regex: /[0-9]/, text: "At least 1 number" },
|
|
22
|
+
{ regex: /[a-z]/, text: "At least 1 lowercase letter" },
|
|
23
|
+
{ regex: /[A-Z]/, text: "At least 1 uppercase letter" },
|
|
24
|
+
] as const;
|
|
25
|
+
|
|
26
|
+
export function getPasswordStrength(password: string): number {
|
|
27
|
+
return passwordRequirements.filter((req) => req.regex.test(password)).length;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const STRENGTH_COLORS = [
|
|
31
|
+
"bg-border",
|
|
32
|
+
"bg-red-500",
|
|
33
|
+
"bg-orange-500",
|
|
34
|
+
"bg-amber-500",
|
|
35
|
+
"bg-emerald-500",
|
|
36
|
+
] as const;
|
|
37
|
+
|
|
38
|
+
export interface PasswordInputProps
|
|
39
|
+
extends Omit<React.ComponentProps<typeof InputGroupInput>, "type" | "size"> {
|
|
40
|
+
/** Render the eye toggle button. */
|
|
41
|
+
visibilityToggle?: boolean;
|
|
42
|
+
/** Render the animated strength meter and requirement checklist. */
|
|
43
|
+
showStrength?: boolean;
|
|
44
|
+
/** Play reveal/hide cues on the visibility toggle. Inert until bindSounds(). */
|
|
45
|
+
sound?: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Password field with visibility toggle and optional strength meter.
|
|
50
|
+
* Composition of InputGroup + Button (coss particles p-input-9 and
|
|
51
|
+
* p-input-group-26).
|
|
52
|
+
*/
|
|
53
|
+
export function PasswordInput({
|
|
54
|
+
visibilityToggle = true,
|
|
55
|
+
showStrength = false,
|
|
56
|
+
sound = true,
|
|
57
|
+
className,
|
|
58
|
+
value: valueProp,
|
|
59
|
+
defaultValue,
|
|
60
|
+
onChange,
|
|
61
|
+
...props
|
|
62
|
+
}: PasswordInputProps) {
|
|
63
|
+
const [visible, setVisible] = React.useState(false);
|
|
64
|
+
const [uncontrolled, setUncontrolled] = React.useState(
|
|
65
|
+
String(defaultValue ?? ""),
|
|
66
|
+
);
|
|
67
|
+
const value = valueProp !== undefined ? String(valueProp) : uncontrolled;
|
|
68
|
+
const score = getPasswordStrength(value);
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<div className={cn("flex w-full flex-col gap-3", className)}>
|
|
72
|
+
<InputGroup>
|
|
73
|
+
<InputGroupInput
|
|
74
|
+
placeholder="Enter your password"
|
|
75
|
+
{...props}
|
|
76
|
+
type={visible ? "text" : "password"}
|
|
77
|
+
value={value}
|
|
78
|
+
onChange={(event) => {
|
|
79
|
+
setUncontrolled(event.target.value);
|
|
80
|
+
onChange?.(event);
|
|
81
|
+
}}
|
|
82
|
+
/>
|
|
83
|
+
{visibilityToggle && (
|
|
84
|
+
<InputGroupAddon align="inline-end">
|
|
85
|
+
<Button
|
|
86
|
+
aria-label={visible ? "Hide password" : "Show password"}
|
|
87
|
+
onClick={() => {
|
|
88
|
+
// Semantic cues from the cuelume palette: bloom = reveal,
|
|
89
|
+
// droplet = dismiss. Press/release stays off to avoid layering.
|
|
90
|
+
if (sound) playCue(visible ? "droplet" : "bloom");
|
|
91
|
+
setVisible((v) => !v);
|
|
92
|
+
}}
|
|
93
|
+
size="icon-xs"
|
|
94
|
+
variant="ghost"
|
|
95
|
+
sound={false}
|
|
96
|
+
>
|
|
97
|
+
<AnimatePresence mode="wait" initial={false}>
|
|
98
|
+
<motion.span
|
|
99
|
+
key={visible ? "off" : "on"}
|
|
100
|
+
className="inline-flex"
|
|
101
|
+
initial={{ opacity: 0, scale: 0.8, filter: "blur(2px)" }}
|
|
102
|
+
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
|
|
103
|
+
exit={{ opacity: 0, scale: 0.8, filter: "blur(2px)" }}
|
|
104
|
+
transition={{ duration: 0.15, ease: EASE }}
|
|
105
|
+
>
|
|
106
|
+
{visible ? (
|
|
107
|
+
<EyeOffIcon aria-hidden="true" />
|
|
108
|
+
) : (
|
|
109
|
+
<EyeIcon aria-hidden="true" />
|
|
110
|
+
)}
|
|
111
|
+
</motion.span>
|
|
112
|
+
</AnimatePresence>
|
|
113
|
+
</Button>
|
|
114
|
+
</InputGroupAddon>
|
|
115
|
+
)}
|
|
116
|
+
</InputGroup>
|
|
117
|
+
|
|
118
|
+
<AnimatePresence initial={false}>
|
|
119
|
+
{showStrength && (
|
|
120
|
+
<motion.div
|
|
121
|
+
className="flex flex-col gap-2 overflow-hidden"
|
|
122
|
+
initial={{ height: 0, opacity: 0 }}
|
|
123
|
+
animate={{ height: "auto", opacity: 1 }}
|
|
124
|
+
exit={{ height: 0, opacity: 0 }}
|
|
125
|
+
transition={{ duration: 0.25, ease: EASE }}
|
|
126
|
+
>
|
|
127
|
+
<div
|
|
128
|
+
aria-label="Password strength"
|
|
129
|
+
aria-valuemax={4}
|
|
130
|
+
aria-valuemin={0}
|
|
131
|
+
aria-valuenow={score}
|
|
132
|
+
className="h-1 w-full overflow-hidden rounded-full bg-border"
|
|
133
|
+
role="progressbar"
|
|
134
|
+
>
|
|
135
|
+
<motion.div
|
|
136
|
+
className={cn("h-full rounded-full", STRENGTH_COLORS[score])}
|
|
137
|
+
animate={{ width: `${(score / 4) * 100}%` }}
|
|
138
|
+
transition={{ duration: 0.4, ease: EASE }}
|
|
139
|
+
/>
|
|
140
|
+
</div>
|
|
141
|
+
<ul
|
|
142
|
+
aria-label="Password requirements"
|
|
143
|
+
className="flex flex-col gap-1"
|
|
144
|
+
>
|
|
145
|
+
{passwordRequirements.map((req) => {
|
|
146
|
+
const met = req.regex.test(value);
|
|
147
|
+
return (
|
|
148
|
+
<li className="flex items-center gap-2 text-xs" key={req.text}>
|
|
149
|
+
{met ? (
|
|
150
|
+
<CheckIcon
|
|
151
|
+
aria-hidden="true"
|
|
152
|
+
className="size-3.5 text-emerald-500"
|
|
153
|
+
/>
|
|
154
|
+
) : (
|
|
155
|
+
<XIcon
|
|
156
|
+
aria-hidden="true"
|
|
157
|
+
className="size-3.5 text-muted-foreground/80"
|
|
158
|
+
/>
|
|
159
|
+
)}
|
|
160
|
+
<span
|
|
161
|
+
className={
|
|
162
|
+
met ? "text-emerald-600" : "text-muted-foreground"
|
|
163
|
+
}
|
|
164
|
+
>
|
|
165
|
+
{req.text}
|
|
166
|
+
<span className="sr-only">
|
|
167
|
+
{met ? " - met" : " - not met"}
|
|
168
|
+
</span>
|
|
169
|
+
</span>
|
|
170
|
+
</li>
|
|
171
|
+
);
|
|
172
|
+
})}
|
|
173
|
+
</ul>
|
|
174
|
+
</motion.div>
|
|
175
|
+
)}
|
|
176
|
+
</AnimatePresence>
|
|
177
|
+
</div>
|
|
178
|
+
);
|
|
179
|
+
}
|