Compare commits

...

4 Commits

Author SHA1 Message Date
Emil Sayahi
ccbe849569 user button, reset password form
todo:
- fix logout -> login bug (set `account` to undefined when `isAuthenticated === false`?)
- forgot password form
- verify account button (add to settings form)
2025-05-16 12:01:33 -04:00
Emil Sayahi
8323267573 settings page
todo:
- forgot password form
- reset password form
2025-05-16 09:21:06 -04:00
Emil Sayahi
a3814532c1 sign-ups & sign-ins
todo:
- forgot password form
- reset password form
- link/unlink providers card
- delete account card
2025-05-15 23:12:32 -04:00
Emil Sayahi
f35fdea153 feat: initial ui package 2025-05-15 18:05:49 -04:00
52 changed files with 7854 additions and 8747 deletions

View File

@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

View File

@@ -18,15 +18,21 @@
"@react-email/components": "^0.0.38",
"better-auth": "^1.2.4",
"better-sqlite3": "^11.9.1",
"jazz-betterauth-server-plugin": "workspace:*",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"jazz-betterauth-client-plugin": "workspace:*",
"jazz-betterauth-server-plugin": "workspace:*",
"jazz-cloud-ui": "workspace:*",
"jazz-inspector": "workspace:*",
"jazz-react": "workspace:*",
"jazz-react-auth-betterauth": "workspace:*",
"jazz-tools": "workspace:*",
"lucide-react": "^0.510.0",
"next": "15.3.1",
"next-themes": "^0.4.6",
"react": "^18.0.0",
"react-dom": "^18.0.0"
"react-dom": "^18.0.0",
"tailwind-merge": "^3.3.0"
},
"devDependencies": {
"@biomejs/biome": "1.9.4",
@@ -38,6 +44,7 @@
"@types/react-dom": "^18",
"react-email": "^4.0.11",
"tailwindcss": "^4",
"tw-animate-css": "^1.2.5",
"typescript": "^5"
}
}

View File

@@ -1,10 +0,0 @@
"use client";
import { useAccount } from "jazz-react";
import { redirect } from "next/navigation";
export default function Page() {
const { logOut } = useAccount({ resolve: { profile: true } });
logOut();
redirect("/");
}

View File

@@ -1,27 +1,124 @@
@import "tailwindcss";
@import "tailwindcss/utilities";
@import "tw-animate-css";
@source "../../node_modules/jazz-cloud-ui/dist";
:root {
--background: #ffffff;
--foreground: #171717;
}
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}

View File

@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { JazzAndAuth } from "@/components/JazzAndAuth";
import { ThemeProvider } from "@/components/theme-provider";
const geistSans = Geist({
variable: "--font-geist-sans",
@@ -24,11 +25,13 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en">
<html lang="en" suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<JazzAndAuth>{children}</JazzAndAuth>
<ThemeProvider attribute="class" defaultTheme="system">
<JazzAndAuth>{children}</JazzAndAuth>
</ThemeProvider>
</body>
</html>
);

View File

@@ -1,5 +0,0 @@
"use client";
import MagicLinkSignIn from "@/components/routes/magic-link/signIn/page";
export default MagicLinkSignIn;

View File

@@ -1,5 +0,0 @@
"use client";
import MagicLinkSignUp from "@/components/routes/magic-link/logIn/page";
export default MagicLinkSignUp;

View File

@@ -1,19 +1,16 @@
"use client";
import { Button } from "@/components/Button";
import { LogoutButton, UserButton } from "jazz-cloud-ui";
import { useAccount, useIsAuthenticated } from "jazz-react";
import { useAuth } from "jazz-react-auth-betterauth";
import Image from "next/image";
import { useCallback } from "react";
export default function Home() {
const { authClient, account, state } = useAuth();
const { account, state } = useAuth();
const hasCredentials = state !== "anonymous";
const { me, logOut } = useAccount({ resolve: { profile: {} } });
const { me } = useAccount({ resolve: { profile: {} } });
const isAuthenticated = useIsAuthenticated();
const signOut = useCallback(() => {
authClient.signOut().catch(console.error).finally(logOut);
}, [logOut, authClient]);
console.log("me", me);
console.log("account", account);
console.log("state", state);
@@ -25,10 +22,11 @@ export default function Home() {
<header className="absolute p-4 top-0 left-0 w-full z-10 flex items-center justify-between gap-4">
<div className="float-start flex gap-4">
{me && hasCredentials && isAuthenticated && (
<>
<Button onClick={signOut}>Sign out</Button>
<Button href="/settings">Settings</Button>
</>
// <>
// <LogoutButton className="w-fit" />
// <Button href="/settings">Settings</Button>
// </>
<UserButton settingsUrl="/settings" />
)}
</div>
<div className="float-end flex gap-4">

View File

@@ -1,7 +1,16 @@
"use client";
import ResetForm from "@/components/forms/Reset";
import { ResetPasswordForm } from "jazz-cloud-ui";
export default function ResetPage() {
return <ResetForm />;
const props = {
signInUrl: "/sign-in",
} as Parameters<typeof ResetPasswordForm>["0"];
return (
<div className="bg-muted flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
<div className="flex w-full max-w-sm flex-col gap-6">
<ResetPasswordForm {...props} />
</div>
</div>
);
}

View File

@@ -1,7 +1,20 @@
"use client";
import SettingsForm from "@/components/forms/Settings";
import { LogoutButton, SettingsForm } from "jazz-cloud-ui";
export default function SettingsPage() {
return <SettingsForm providers={["github"]} />;
const props = {
providers: ["github"],
deleteAccountRedirectUrl: "/",
} as Parameters<typeof SettingsForm>["0"];
return (
<div className="bg-muted flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
<header className="absolute p-4 top-0 left-0 w-fit z-10 flex items-center justify-between gap-4">
<LogoutButton className="float-start w-fit" redirectUrl="/" />
</header>
<div className="flex w-full max-w-sm flex-col gap-6">
<SettingsForm {...props} />
</div>
</div>
);
}

View File

@@ -1,7 +1,24 @@
"use client";
import SignInForm from "@/components/forms/SignIn";
import { LoginForm } from "jazz-cloud-ui";
export default function SignInPage() {
return <SignInForm providers={["github"]} />;
const props = {
redirectUrl: "/",
operation: "sign-in",
supportOtp: true,
supportMagicLink: true,
providers: ["github"],
signUpUrl: "/sign-up",
forgotPasswordUrl: "/forgot",
ssoCallbackUrl: `${window.location.origin}`,
magicLinkCallbackUrl: `${window.location.origin}`,
} as Parameters<typeof LoginForm>["0"];
return (
<div className="bg-muted flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
<div className="flex w-full max-w-sm flex-col gap-6">
<LoginForm {...props} />
</div>
</div>
);
}

View File

@@ -1,7 +1,24 @@
"use client";
import SignUpForm from "@/components/forms/SignUp";
import { LoginForm } from "jazz-cloud-ui";
export default function SignUpPage() {
return <SignUpForm providers={["github"]} />;
const props = {
redirectUrl: "/",
operation: "sign-up",
supportOtp: true,
supportMagicLink: true,
providers: ["github"],
signInUrl: "/sign-in",
forgotPasswordUrl: "/forgot",
ssoCallbackUrl: `${window.location.origin}`,
magicLinkCallbackUrl: `${window.location.origin}`,
} as Parameters<typeof LoginForm>["0"];
return (
<div className="bg-muted flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
<div className="flex w-full max-w-sm flex-col gap-6">
<LoginForm {...props} />
</div>
</div>
);
}

View File

@@ -1,10 +0,0 @@
"use client";
import { useAuth } from "jazz-react-auth-betterauth";
import { redirect } from "next/navigation";
export default function Page() {
const { logIn } = useAuth();
logIn().then(redirect("/"));
return null;
}

View File

@@ -1,10 +0,0 @@
"use client";
import { useAuth } from "jazz-react-auth-betterauth";
import { redirect } from "next/navigation";
export default function Page() {
const { signIn } = useAuth();
signIn().then(redirect("/"));
return null;
}

View File

@@ -1,85 +0,0 @@
import { Button } from "@/components/Button";
import { AccountsType, useAuth } from "jazz-react-auth-betterauth";
export const AccountProviders = ({
setLoading,
setError,
accounts,
}: {
setLoading: React.Dispatch<React.SetStateAction<boolean>>;
setError: React.Dispatch<React.SetStateAction<Error | undefined>>;
accounts: AccountsType | undefined;
}) => {
const auth = useAuth();
return (
<table className="w-full text-sm border-full border-collapse">
<thead className="text-xs">
<tr>
<th scope="col" className="px-6 py-3">
Provider
</th>
<th scope="col" className="px-6 py-3">
Created
</th>
<th scope="col" className="px-6 py-3">
Updated
</th>
<th scope="col" className="px-6 py-3">
Scopes
</th>
</tr>
</thead>
<tbody>
{!accounts?.data?.length && "No authentication providers found"}
{accounts?.data &&
accounts.data.map((account) => (
<tr key={account.id} className="border-b">
<th
scope="row"
className="px-6 py-4 font-medium whitespace-nowrap"
>
{account.provider}
</th>
<td className="px-6 py-4">
{account.createdAt.toLocaleString()}
</td>
<td className="px-6 py-4">
{account.updatedAt.toLocaleString()}
</td>
<td className="px-6 py-4">{account.scopes.join(", ")}</td>
<td className="px-6 py-4">
<Button
variant="secondary"
className="relative"
onClick={async (e) => {
e.preventDefault();
setLoading(true);
const { error } = await auth.authClient.unlinkAccount({
providerId: account.provider,
accountId: account.id,
});
const errorMessage = error?.message ?? error?.statusText;
setError(
error
? {
...error,
name: error.statusText,
message:
errorMessage && errorMessage.length > 0
? errorMessage
: "An error occurred",
}
: undefined,
);
setLoading(false);
}}
>
Unlink
</Button>
</td>
</tr>
))}
</tbody>
</table>
);
};

View File

@@ -1,222 +0,0 @@
import { AccountProviders } from "@/components/AccountProviders";
import { Button } from "@/components/Button";
import { DeleteAccountButton } from "@/components/DeleteAccountButton";
import { Loading } from "@/components/Loading";
import { SSOButton } from "@/components/SSOButton";
import { useAccount, useIsAuthenticated } from "jazz-react";
import { useAuth } from "jazz-react-auth-betterauth";
import type {
AccountsType,
FullAuthClient,
SSOProviderType,
} from "jazz-react-auth-betterauth";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
const title = "Settings";
export default function SettingsForm({
providers,
}: {
providers?: SSOProviderType[];
}) {
const router = useRouter();
const { authClient, account, state } = useAuth();
const hasCredentials = state !== "anonymous";
const [accounts, setAccounts] = useState<AccountsType | undefined>(undefined);
useEffect(() => {
return authClient.useSession.subscribe(() => {
authClient.listAccounts().then((x) => setAccounts(x));
});
}, [authClient]);
const [status, setStatus] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<Error | undefined>(undefined);
const [otpSentStatus, setOtpSentStatus] = useState<boolean>(false);
const [otpStatus, setOtpStatus] = useState<boolean>(false);
const [otp, setOtp] = useState<string>("");
const { me, logOut } = useAccount({ resolve: { profile: true } });
const isAuthenticated = useIsAuthenticated();
const signOut = useCallback(() => {
authClient
.signOut()
.catch(console.error)
.finally(() => {
logOut();
router.push("/");
});
}, [logOut, authClient]);
return (
<>
<header className="absolute p-4 top-0 left-0 w-full z-10 flex items-center justify-between gap-4">
<div className="float-start">
{me && hasCredentials && account && isAuthenticated && (
<Button className="float-start" onClick={signOut}>
Sign out
</Button>
)}
</div>
</header>
<div className="min-h-screen flex flex-col justify-center font-[family-name:var(--font-geist-sans)]">
<div className="max-w-md flex flex-col gap-8 w-full px-6 py-12 mx-auto">
<h1 className="text-stone-950 dark:text-white font-display text-5xl lg:text-6xl font-medium tracking-tighter mb-2">
{title}
</h1>
{status && account && !account?.emailVerified && (
<div>
Instructions to verify your account have been sent to{" "}
{account.email}, if an account with that email address exists.
</div>
)}
{(status || otpStatus) && account && account.emailVerified && (
<div>Your account has been successfully verified.</div>
)}
{error && <div>{error.message}</div>}
{loading && <Loading />}
<AccountProviders
accounts={accounts}
setLoading={setLoading}
setError={setError}
/>
{accounts?.data &&
providers?.map((x) => {
return (
accounts.data.find((y) => y.provider === x) === undefined && (
<SSOButton
link={true}
provider={x}
setLoading={setLoading}
setError={setError}
/>
)
);
})}
{account && account.emailVerified && <p>Account verified.</p>}
{account && !account.emailVerified && (
<>
<Button
variant="secondary"
className="relative"
onClick={async (e) => {
e.preventDefault();
setLoading(true);
const { data, error } =
await authClient.sendVerificationEmail({
email: account.email,
callbackURL: `${window.location.origin}`,
});
setStatus(data?.status ?? false);
const errorMessage = error?.message ?? error?.statusText;
setError(
error
? {
...error,
name: error.statusText,
message:
errorMessage && errorMessage.length > 0
? errorMessage
: "An error occurred",
}
: undefined,
);
setLoading(false);
}}
>
Send verification link
</Button>
<Button
variant="secondary"
className="relative"
onClick={async (e) => {
e.preventDefault();
setLoading(true);
const { data, error } = await (
authClient as FullAuthClient
).emailOtp.sendVerificationOtp({
email: account.email,
type: "email-verification",
});
setStatus(data?.success ?? false);
setOtpSentStatus(data?.success ?? false);
const errorMessage = error?.message ?? error?.statusText;
setError(
error
? {
...error,
name: error.statusText,
message:
errorMessage && errorMessage.length > 0
? errorMessage
: "An error occurred",
}
: undefined,
);
setLoading(false);
}}
>
Send verification one-time password
</Button>
</>
)}
{otpSentStatus && account && !account.emailVerified && (
<form
className="flex flex-col gap-6"
onSubmit={async (e) => {
e.preventDefault();
setLoading(true);
const { data, error } = await (
authClient as FullAuthClient
).emailOtp.verifyEmail({
email: account.email,
otp: otp,
});
setOtpStatus(data?.status ?? false);
const errorMessage = error?.message ?? error?.statusText;
setError(
error
? {
...error,
name: error.statusText,
message:
errorMessage && errorMessage.length > 0
? errorMessage
: "An error occurred",
}
: undefined,
);
setLoading(false);
}}
>
<div>
<label htmlFor="otp">One-time password</label>
<input
id="otp"
value={otp}
disabled={loading}
onChange={(e) => setOtp(e.target.value)}
/>
</div>
<Button type={"submit"} disabled={loading}>
Submit
</Button>
</form>
)}
<DeleteAccountButton
setLoading={setLoading}
setError={setError}
callbackURL={`${window.location.origin}/delete-account`}
/>
</div>
</div>
</>
);
}

View File

@@ -1,245 +0,0 @@
import { Button } from "@/components/Button";
import { Loading } from "@/components/Loading";
import { SSOButton } from "@/components/SSOButton";
import { useAuth } from "jazz-react-auth-betterauth";
import type {
FullAuthClient,
SSOProviderType,
} from "jazz-react-auth-betterauth";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
const title = "Sign In";
export default function SignInForm({
providers,
}: {
providers?: SSOProviderType[];
}) {
const router = useRouter();
const auth = useAuth();
const [email, setEmail] = useState<string>("");
const [password, setPassword] = useState<string>("");
const [rememberMe, setRememberMe] = useState(true);
const [otp, setOtp] = useState<string>("");
const [otpStatus, setOtpStatus] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<Error | undefined>(undefined);
return (
<div className="min-h-screen flex flex-col justify-center">
<h1 className="sr-only">{title}</h1>
<div className="max-w-md flex flex-col gap-8 w-full px-6 py-12 mx-auto">
{otpStatus && (
<div>A one-time password has been sent to your email.</div>
)}
{error && <div>{error.message}</div>}
{loading && <Loading />}
<form
className="flex flex-col gap-6"
onSubmit={async (e) => {
e.preventDefault();
setLoading(true);
if (!otpStatus) {
await auth.authClient.signIn.email(
{
email,
password,
rememberMe,
},
{
onSuccess: async () => {
await auth.logIn();
router.push("/");
},
onError: (error) => {
setError(error.error);
},
},
);
} else {
const { data, error } = await (
auth.authClient as FullAuthClient
).signIn.emailOtp({
email: email,
otp: otp,
});
const errorMessage = error?.message ?? error?.statusText;
setError(
error
? {
...error,
name: error.statusText,
message:
errorMessage && errorMessage.length > 0
? errorMessage
: "An error occurred",
}
: undefined,
);
if (data) {
await auth.logIn();
router.push("/");
}
}
setLoading(false);
}}
>
<div>
<label htmlFor="email-address">Email address</label>
<input
id="email-address"
value={email}
disabled={loading}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
{!otpStatus && (
<div>
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
value={password}
disabled={loading}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
)}
{otpStatus && (
<div>
<label htmlFor="otp">One-time password</label>
<input
id="otp"
value={otp}
disabled={loading}
onChange={(e) => setOtp(e.target.value)}
/>
</div>
)}
<div className="items-center">
<div>
<label htmlFor="remember-me">Remember me</label>
<input
id="remember-me"
type="checkbox"
checked={rememberMe}
disabled={loading}
onChange={(e) => setRememberMe(e.target.checked)}
/>
</div>
<Link href="/forgot" className="text-sm float-right">
Forgot password?
</Link>
</div>
<Button type="submit" disabled={loading}>
Sign in
</Button>
</form>
<div className="flex items-center gap-4">
<hr className="flex-1" />
<p className="text-center">or</p>
<hr className="flex-1" />
</div>
<div className="flex flex-col gap-4">
{providers?.map((x) => {
return (
<SSOButton
callbackURL={`${window.location.origin}/social/logIn`}
provider={x}
setLoading={setLoading}
setError={setError}
/>
);
})}
<Button
variant="secondary"
className="relative"
onClick={async (e) => {
e.preventDefault();
setLoading(true);
const { error } = await (
auth.authClient as FullAuthClient
).signIn.magicLink({
email: email,
callbackURL: `${window.location.origin}/magic-link/logIn`,
});
const errorMessage = error?.message ?? error?.statusText;
setError(
error
? {
...error,
name: error.statusText,
message:
errorMessage && errorMessage.length > 0
? errorMessage
: "An error occurred",
}
: undefined,
);
setLoading(false);
}}
>
<Image
src="/link.svg"
alt="Link icon"
className="absolute left-3"
width={16}
height={16}
/>
Sign in with magic link
</Button>
<Button
variant="secondary"
className="relative"
onClick={async (e) => {
e.preventDefault();
setLoading(true);
const { data, error } = await (
auth.authClient as FullAuthClient
).emailOtp.sendVerificationOtp({
email: email,
type: "sign-in",
});
setOtpStatus(data?.success ?? false);
const errorMessage = error?.message ?? error?.statusText;
setError(
error
? {
...error,
name: error.statusText,
message:
errorMessage && errorMessage.length > 0
? errorMessage
: "An error occurred",
}
: undefined,
);
setLoading(false);
}}
>
<Image
src="/mail.svg"
alt="Mail icon"
className="absolute left-3"
width={16}
height={16}
/>
Sign in with one-time password
</Button>
</div>
<p className="text-sm">
Don't have an account? <Link href="/sign-up">Sign up</Link>
</p>
</div>
</div>
);
}

View File

@@ -1,257 +0,0 @@
import { Button } from "@/components/Button";
import { Loading } from "@/components/Loading";
import { SSOButton } from "@/components/SSOButton";
import { useAuth } from "jazz-react-auth-betterauth";
import type {
FullAuthClient,
SSOProviderType,
} from "jazz-react-auth-betterauth";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
const title = "Sign Up";
export default function SignUpForm({
providers,
}: {
providers?: SSOProviderType[];
}) {
const router = useRouter();
const auth = useAuth();
const [name, setName] = useState<string>("");
const [email, setEmail] = useState<string>("");
const [password, setPassword] = useState<string>("");
const [confirmPassword, setConfirmPassword] = useState<string>("");
const [otp, setOtp] = useState<string>("");
const [otpStatus, setOtpStatus] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<Error | undefined>(undefined);
return (
<div className="min-h-screen flex flex-col justify-center">
<h1 className="sr-only">{title}</h1>
<div className="max-w-md flex flex-col gap-8 w-full px-6 py-12 mx-auto">
{otpStatus && (
<div>A one-time password has been sent to your email.</div>
)}
{error && <div>{error.message}</div>}
{loading && <Loading />}
<form
className="flex flex-col gap-6"
onSubmit={async (e) => {
e.preventDefault();
setLoading(true);
if (password !== confirmPassword) {
setError(new Error("Passwords do not match"));
setLoading(false);
return;
}
if (!otpStatus) {
await auth.authClient.signUp.email(
{
email,
password,
name,
},
{
onSuccess: async () => {
await auth.signIn();
router.push("/");
},
onError: (error) => {
setError(error.error);
},
},
);
} else {
const { data, error } = await (
auth.authClient as FullAuthClient
).signIn.emailOtp({
email: email,
otp: otp,
});
const errorMessage = error?.message ?? error?.statusText;
setError(
error
? {
...error,
name: error.statusText,
message:
errorMessage && errorMessage.length > 0
? errorMessage
: "An error occurred",
}
: undefined,
);
if (data) {
await auth.signIn();
router.push("/");
}
}
setLoading(false);
}}
>
<div>
<label htmlFor="full-name">Full name</label>
<input
id="full-name"
value={name}
disabled={loading}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div>
<label htmlFor="email-address">Email address</label>
<input
id="email-address"
value={email}
disabled={loading}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
{!otpStatus && (
<>
<div>
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
value={password}
disabled={loading}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<div>
<label htmlFor="confirm-password">Confirm password</label>
<input
id="confirm-password"
type="password"
value={confirmPassword}
disabled={loading}
onChange={(e) => setConfirmPassword(e.target.value)}
/>
</div>
</>
)}
{otpStatus && (
<div>
<label htmlFor="otp">One-time password</label>
<input
id="otp"
value={otp}
disabled={loading}
onChange={(e) => setOtp(e.target.value)}
/>
</div>
)}
<Button type="submit" disabled={loading}>
Sign up
</Button>
</form>
<div className="flex items-center gap-4">
<hr className="flex-1" />
<p className="text-center">or</p>
<hr className="flex-1" />
</div>
<div className="flex flex-col gap-4">
{providers?.map((x) => {
return (
<SSOButton
callbackURL={`${window.location.origin}/social/signIn`}
provider={x}
setLoading={setLoading}
setError={setError}
/>
);
})}
<Button
variant="secondary"
className="relative"
onClick={async (e) => {
e.preventDefault();
setLoading(true);
const { error } = await (
auth.authClient as FullAuthClient
).signIn.magicLink({
email: email,
callbackURL: `${window.location.origin}/magic-link/signIn`,
});
const errorMessage = error?.message ?? error?.statusText;
setError(
error
? {
...error,
name: error.statusText,
message:
errorMessage && errorMessage.length > 0
? errorMessage
: "An error occurred",
}
: undefined,
);
setLoading(false);
}}
>
<Image
src="/link.svg"
alt="Link icon"
className="absolute left-3"
width={16}
height={16}
/>
Sign up with magic link
</Button>
<Button
variant="secondary"
className="relative"
onClick={async (e) => {
e.preventDefault();
setLoading(true);
const { data, error } = await (
auth.authClient as FullAuthClient
).emailOtp.sendVerificationOtp({
email: email,
type: "sign-in",
});
setOtpStatus(data?.success ?? false);
const errorMessage = error?.message ?? error?.statusText;
setError(
error
? {
...error,
name: error.statusText,
message:
errorMessage && errorMessage.length > 0
? errorMessage
: "An error occurred",
}
: undefined,
);
setLoading(false);
}}
>
<Image
src="/mail.svg"
alt="Mail icon"
className="absolute left-3"
width={16}
height={16}
/>
Sign up with one-time password
</Button>
</div>
<p className="text-sm">
Already have an account? <Link href="/sign-in">Sign in</Link>
</p>
</div>
</div>
);
}

View File

@@ -1,21 +0,0 @@
import { useAuth } from "jazz-react-auth-betterauth";
import { useRouter } from "next/navigation";
export default function Page() {
const router = useRouter();
const auth = useAuth();
const searchParams = new URLSearchParams(window.location.search);
const error = searchParams.get("error");
if (!error) {
auth.logIn().then(() => router.push("/"));
return null;
} else {
return (
<div className="min-h-screen flex flex-col justify-center">
<div className="max-w-md flex flex-col gap-8 w-full px-6 py-12 mx-auto">
<div>{error}</div>
</div>
</div>
);
}
}

View File

@@ -1,21 +0,0 @@
import { useAuth } from "jazz-react-auth-betterauth";
import { useRouter } from "next/navigation";
export default function Page() {
const router = useRouter();
const auth = useAuth();
const searchParams = new URLSearchParams(window.location.search);
const error = searchParams.get("error");
if (!error) {
auth.signIn().then(() => router.push("/"));
return null;
} else {
return (
<div className="min-h-screen flex flex-col justify-center">
<div className="max-w-md flex flex-col gap-8 w-full px-6 py-12 mx-auto">
<div>{error}</div>
</div>
</div>
);
}
}

View File

@@ -0,0 +1,11 @@
"use client";
import { ThemeProvider as NextThemesProvider } from "next-themes";
import * as React from "react";
export function ThemeProvider({
children,
...props
}: React.ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}

View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

176
homepage/pnpm-lock.yaml generated
View File

@@ -13,7 +13,7 @@ importers:
version: 1.4.0
'@headlessui/react':
specifier: ^2.2.0
version: 2.2.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
version: 2.2.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@icons-pack/react-simple-icons':
specifier: ^9.1.0
version: 9.7.0(react@18.3.1)
@@ -71,7 +71,7 @@ importers:
version: 3.0.19(postcss@8.5.3)
'@types/node':
specifier: ^20
version: 20.17.46
version: 20.17.47
'@types/react':
specifier: ^18
version: 18.3.21
@@ -159,7 +159,7 @@ importers:
version: 1.9.4
'@types/node':
specifier: ^20
version: 20.17.46
version: 20.17.47
'@types/react':
specifier: ^18
version: 18.3.21
@@ -192,7 +192,7 @@ importers:
version: link:../design-system
'@headlessui/react':
specifier: ^2.2.0
version: 2.2.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
version: 2.2.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@icons-pack/react-simple-icons':
specifier: ^9.1.0
version: 9.7.0(react@18.3.1)
@@ -210,10 +210,10 @@ importers:
version: 1.2.6(@types/react-dom@18.3.7(@types/react@18.3.21))(@types/react@18.3.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@shikijs/transformers':
specifier: ^3.2.1
version: 3.4.0
version: 3.4.1
'@shikijs/twoslash':
specifier: ^3.2.1
version: 3.4.0(typescript@5.7.3)
version: 3.4.1(typescript@5.7.3)
'@stefanprobst/rehype-extract-toc':
specifier: ^2.2.0
version: 2.2.1
@@ -309,7 +309,7 @@ importers:
version: 18.3.1(react@18.3.1)
shiki:
specifier: ^3.2.1
version: 3.4.0
version: 3.4.1
tailwind-merge:
specifier: ^1.14.0
version: 1.14.0
@@ -328,7 +328,7 @@ importers:
version: 7946.0.16
'@types/node':
specifier: ^20
version: 20.17.46
version: 20.17.47
'@types/react':
specifier: ^18
version: 18.3.21
@@ -497,8 +497,8 @@ packages:
'@gerrit0/mini-shiki@1.27.2':
resolution: {integrity: sha512-GeWyHz8ao2gBiUW4OJnQDxXQnFgZQwwQk05t/CVVgNBN7/rK8XZ7xY6YhLVv9tH3VppWWmr9DCl3MwemB/i+Og==}
'@headlessui/react@2.2.2':
resolution: {integrity: sha512-zbniWOYBQ8GHSUIOPY7BbdIn6PzUOq0z41RFrF30HbjsxG6Rrfk+6QulR8Kgf2Vwj2a/rE6i62q5vo+2gI5dJA==}
'@headlessui/react@2.2.3':
resolution: {integrity: sha512-hgOJGXPifPlOczIeSwX8OjLWRJ5XdYApZFf7DeCbCrO1PXHkPhNTRrA9ZwJsgAG7SON1i2JcvIreF/kbgtJeaQ==}
engines: {node: '>=10'}
peerDependencies:
react: ^18 || ^19 || ^19.0.0-rc
@@ -1128,37 +1128,37 @@ packages:
'@selderee/plugin-htmlparser2@0.11.0':
resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==}
'@shikijs/core@3.4.0':
resolution: {integrity: sha512-0YOzTSRDn/IAfQWtK791gs1u8v87HNGToU6IwcA3K7nPoVOrS2Dh6X6A6YfXgPTSkTwR5y6myk0MnI0htjnwrA==}
'@shikijs/core@3.4.1':
resolution: {integrity: sha512-GCqSd3KXRTKX1sViP7fIyyyf6do2QVg+fTd4IT00ucYCVSKiSN8HbFbfyjGsoZePNKWcQqXe4U4rrz2IVldG5A==}
'@shikijs/engine-javascript@3.4.0':
resolution: {integrity: sha512-1ywDoe+z/TPQKj9Jw0eU61B003J9DqUFRfH+DVSzdwPUFhR7yOmfyLzUrFz0yw8JxFg/NgzXoQyyykXgO21n5Q==}
'@shikijs/engine-javascript@3.4.1':
resolution: {integrity: sha512-oGvRqN3Bsk+cGzmCb/5Kt/LfD7uyA8vCUUawyqmLti/AYNV7++zIZFEW8JwW5PrpPNWWx9RcZ/chnYLedzlVIQ==}
'@shikijs/engine-oniguruma@1.29.2':
resolution: {integrity: sha512-7iiOx3SG8+g1MnlzZVDYiaeHe7Ez2Kf2HrJzdmGwkRisT7r4rak0e655AcM/tF9JG/kg5fMNYlLLKglbN7gBqA==}
'@shikijs/engine-oniguruma@3.4.0':
resolution: {integrity: sha512-zwcWlZ4OQuJ/+1t32ClTtyTU1AiDkK1lhtviRWoq/hFqPjCNyLj22bIg9rB7BfoZKOEOfrsGz7No33BPCf+WlQ==}
'@shikijs/engine-oniguruma@3.4.1':
resolution: {integrity: sha512-p8I5KWgEDUcXRif9JjJUZtNeqCyxZ8xcslecDJMigsqSZfokwqQIsH4aGpdjzmDf8LIWvT+C3TCxnJQVaPmCbQ==}
'@shikijs/langs@3.4.0':
resolution: {integrity: sha512-bQkR+8LllaM2duU9BBRQU0GqFTx7TuF5kKlw/7uiGKoK140n1xlLAwCgXwSxAjJ7Htk9tXTFwnnsJTCU5nDPXQ==}
'@shikijs/langs@3.4.1':
resolution: {integrity: sha512-v5A5ApJYcrcPLHcwAi0bViUU+Unh67UaXU9gGX3qfr2z3AqlqSZbC00W/3J4+tfGJASzwrWDro2R1er6SsCL1Q==}
'@shikijs/themes@3.4.0':
resolution: {integrity: sha512-YPP4PKNFcFGLxItpbU0ZW1Osyuk8AyZ24YEFaq04CFsuCbcqydMvMUTi40V2dkc0qs1U2uZFrnU6s5zI6IH+uA==}
'@shikijs/themes@3.4.1':
resolution: {integrity: sha512-XOJgs55mVVMZtNVJx1NVmdcfXG9HIyZGh7qpCw/Ok5UMjWgkmb8z15TgcmF3ItvHItijiIMl9BLcNO/tFSGl1w==}
'@shikijs/transformers@3.4.0':
resolution: {integrity: sha512-GrGaOj1/I6h75IU0VvjdWDpqGCynx0bqHzd1rErBTGxrcmusYIBhrV7aEySWyJ6HHb9figeXfcNxUFS1HKUfBw==}
'@shikijs/transformers@3.4.1':
resolution: {integrity: sha512-Z3lbVQXHiXLC0bjDuGqsF3AAvvv4lQMoAXqIINZiOBgsk6CrnPZy0E2A+QUqv/MUaqp5qtYGsKDUJWbFE+buXw==}
'@shikijs/twoslash@3.4.0':
resolution: {integrity: sha512-RM15Q6XK+renUX7tN/iUYR2W1qSojTm6kcJwD1FEP0YQoMn7E6Ogr9CqHNYfdDpT7EZBJvx0N96E/pTymWpSuQ==}
'@shikijs/twoslash@3.4.1':
resolution: {integrity: sha512-i6C+sxAK5age1ifiVqyUQSVEj+l4NdMnqc2QN0wN4hsNM43Q/ogYbNxXQHvqWKwN+TwwuTMCaT15ziPaUbfj4A==}
peerDependencies:
typescript: '>=5.5.0'
'@shikijs/types@1.29.2':
resolution: {integrity: sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw==}
'@shikijs/types@3.4.0':
resolution: {integrity: sha512-EUT/0lGiE//7j5N/yTMNMT3eCWNcHJLrRKxT0NDXWIfdfSmFJKfPX7nMmRBrQnWboAzIsUziCThrYMMhjbMS1A==}
'@shikijs/types@3.4.1':
resolution: {integrity: sha512-4flT+pToGqRBb0UhGqXTV7rCqUS3fhc8z3S2Djc3E5USKhXwadeKGFVNB2rKXfohlrEozNJMtMiZaN8lfdj/ZQ==}
'@shikijs/vscode-textmate@10.0.2':
resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
@@ -1590,8 +1590,8 @@ packages:
'@types/ms@2.1.0':
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
'@types/node@20.17.46':
resolution: {integrity: sha512-0PQHLhZPWOxGW4auogW0eOQAuNIlCYvibIpG67ja0TOJ6/sehu+1en7sfceUn+QQtx4Rk3GxbLNwPh0Cav7TWw==}
'@types/node@20.17.47':
resolution: {integrity: sha512-3dLX0Upo1v7RvUimvxLeXqwrfyKxUINk0EAM83swP2mlSUcwV73sZy8XhNz8bcZ3VbsfQyC/y6jRdL5tgCNpDQ==}
'@types/prop-types@15.7.14':
resolution: {integrity: sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==}
@@ -1852,8 +1852,8 @@ packages:
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
engines: {node: '>=6'}
caniuse-lite@1.0.30001717:
resolution: {integrity: sha512-auPpttCq6BDEG8ZAuHJIplGw6GODhjw+/11e7IjpnYCxZcW/ONgPs0KVBJ0d1bY3e2+7PRe5RCLyP+PfwVgkYw==}
caniuse-lite@1.0.30001718:
resolution: {integrity: sha512-AflseV1ahcSunK53NfEs9gFWgOEmzr0f+kaMFA4xiLZlr9Hzt7HxcSpIFcnNCUkz6R6dWKa54rUz3HUmI3nVcw==}
ccount@2.0.1:
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
@@ -1939,8 +1939,8 @@ packages:
d3-voronoi@1.1.2:
resolution: {integrity: sha512-RhGS1u2vavcO7ay7ZNAPo4xeDh/VYeGof3x5ZLJBQgYhLegxr3s5IykvWmJ94FTU6mcbtp4sloqZ54mP6R4Utw==}
debug@4.4.0:
resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==}
debug@4.4.1:
resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==}
engines: {node: '>=6.0'}
peerDependencies:
supports-color: '*'
@@ -2005,8 +2005,8 @@ packages:
eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
electron-to-chromium@1.5.151:
resolution: {integrity: sha512-Rl6uugut2l9sLojjS4H4SAr3A4IgACMLgpuEMPYCVcKydzfyPrn5absNRju38IhQOf/NwjJY8OGWjlteqYeBCA==}
electron-to-chromium@1.5.155:
resolution: {integrity: sha512-ps5KcGGmwL8VaeJlvlDlu4fORQpv3+GIcF5I3f9tUKUlJ/wsysh6HU8P5L1XWRYeXfA0oJd4PyM8ds8zTFf6Ng==}
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
@@ -3029,8 +3029,8 @@ packages:
shiki@0.14.7:
resolution: {integrity: sha512-dNPAPrxSc87ua2sKJ3H5dQ/6ZaY8RNnaAqK+t0eG7p0Soi2ydiqbGOTaZCqaYvA/uZYfS1LJnemt3Q+mSfcPCg==}
shiki@3.4.0:
resolution: {integrity: sha512-Ni80XHcqhOEXv5mmDAvf5p6PAJqbUc/RzFeaOqk+zP5DLvTPS3j0ckvA+MI87qoxTQ5RGJDVTbdl/ENLSyyAnQ==}
shiki@3.4.1:
resolution: {integrity: sha512-PSnoczt+iWIOB4iRQ+XVPFtTuN1FcmuYzPgUBZTSv5pC6CozssIx2M4O5n4S9gJlUu9A3FxMU0ZPaHflky/6LA==}
signal-exit@4.1.0:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
@@ -3167,8 +3167,8 @@ packages:
uglify-js:
optional: true
terser@5.39.0:
resolution: {integrity: sha512-LBAhFyLho16harJoWMg/nZsQYgTrg5jXOn2nCYjRUcZZEdE3qa2zb8QEDRUGVZBW4rlazf2fxkg8tztybTaqWw==}
terser@5.39.2:
resolution: {integrity: sha512-yEPUmWve+VA78bI71BW70Dh0TuV4HHd+I5SHOAfS1+QBOmvmCiiffgjR8ryyEd3KIfvPGFqoADt8LdQ6XpXIvg==}
engines: {node: '>=10'}
hasBin: true
@@ -3377,9 +3377,9 @@ packages:
y18n@4.0.3:
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
yaml@2.7.1:
resolution: {integrity: sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==}
engines: {node: '>= 14'}
yaml@2.8.0:
resolution: {integrity: sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==}
engines: {node: '>= 14.6'}
hasBin: true
yargs-parser@18.1.3:
@@ -3525,7 +3525,7 @@ snapshots:
'@shikijs/types': 1.29.2
'@shikijs/vscode-textmate': 10.0.2
'@headlessui/react@2.2.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
'@headlessui/react@2.2.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@floating-ui/react': 0.26.28(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@react-aria/focus': 3.20.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -4066,16 +4066,16 @@ snapshots:
domhandler: 5.0.3
selderee: 0.11.0
'@shikijs/core@3.4.0':
'@shikijs/core@3.4.1':
dependencies:
'@shikijs/types': 3.4.0
'@shikijs/types': 3.4.1
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
hast-util-to-html: 9.0.5
'@shikijs/engine-javascript@3.4.0':
'@shikijs/engine-javascript@3.4.1':
dependencies:
'@shikijs/types': 3.4.0
'@shikijs/types': 3.4.1
'@shikijs/vscode-textmate': 10.0.2
oniguruma-to-es: 4.3.3
@@ -4084,28 +4084,28 @@ snapshots:
'@shikijs/types': 1.29.2
'@shikijs/vscode-textmate': 10.0.2
'@shikijs/engine-oniguruma@3.4.0':
'@shikijs/engine-oniguruma@3.4.1':
dependencies:
'@shikijs/types': 3.4.0
'@shikijs/types': 3.4.1
'@shikijs/vscode-textmate': 10.0.2
'@shikijs/langs@3.4.0':
'@shikijs/langs@3.4.1':
dependencies:
'@shikijs/types': 3.4.0
'@shikijs/types': 3.4.1
'@shikijs/themes@3.4.0':
'@shikijs/themes@3.4.1':
dependencies:
'@shikijs/types': 3.4.0
'@shikijs/types': 3.4.1
'@shikijs/transformers@3.4.0':
'@shikijs/transformers@3.4.1':
dependencies:
'@shikijs/core': 3.4.0
'@shikijs/types': 3.4.0
'@shikijs/core': 3.4.1
'@shikijs/types': 3.4.1
'@shikijs/twoslash@3.4.0(typescript@5.7.3)':
'@shikijs/twoslash@3.4.1(typescript@5.7.3)':
dependencies:
'@shikijs/core': 3.4.0
'@shikijs/types': 3.4.0
'@shikijs/core': 3.4.1
'@shikijs/types': 3.4.1
twoslash: 0.3.1(typescript@5.7.3)
typescript: 5.7.3
transitivePeerDependencies:
@@ -4116,7 +4116,7 @@ snapshots:
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
'@shikijs/types@3.4.0':
'@shikijs/types@3.4.1':
dependencies:
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
@@ -5334,7 +5334,7 @@ snapshots:
'@types/ms@2.1.0': {}
'@types/node@20.17.46':
'@types/node@20.17.47':
dependencies:
undici-types: 6.19.8
@@ -5365,26 +5365,26 @@ snapshots:
'@typescript/twoslash@3.1.0':
dependencies:
'@typescript/vfs': 1.3.5
debug: 4.4.0
debug: 4.4.1
lz-string: 1.5.0
transitivePeerDependencies:
- supports-color
'@typescript/vfs@1.3.4':
dependencies:
debug: 4.4.0
debug: 4.4.1
transitivePeerDependencies:
- supports-color
'@typescript/vfs@1.3.5':
dependencies:
debug: 4.4.0
debug: 4.4.1
transitivePeerDependencies:
- supports-color
'@typescript/vfs@1.6.1(typescript@5.7.3)':
dependencies:
debug: 4.4.0
debug: 4.4.1
typescript: 5.7.3
transitivePeerDependencies:
- supports-color
@@ -5547,7 +5547,7 @@ snapshots:
autoprefixer@10.4.21(postcss@8.5.3):
dependencies:
browserslist: 4.24.5
caniuse-lite: 1.0.30001717
caniuse-lite: 1.0.30001718
fraction.js: 4.3.7
normalize-range: 0.1.2
picocolors: 1.1.1
@@ -5594,8 +5594,8 @@ snapshots:
browserslist@4.24.5:
dependencies:
caniuse-lite: 1.0.30001717
electron-to-chromium: 1.5.151
caniuse-lite: 1.0.30001718
electron-to-chromium: 1.5.155
node-releases: 2.0.19
update-browserslist-db: 1.1.3(browserslist@4.24.5)
@@ -5609,7 +5609,7 @@ snapshots:
camelcase@5.3.1: {}
caniuse-lite@1.0.30001717: {}
caniuse-lite@1.0.30001718: {}
ccount@2.0.1: {}
@@ -5698,7 +5698,7 @@ snapshots:
d3-voronoi@1.1.2: {}
debug@4.4.0:
debug@4.4.1:
dependencies:
ms: 2.1.3
@@ -5751,7 +5751,7 @@ snapshots:
eastasianwidth@0.2.0: {}
electron-to-chromium@1.5.151: {}
electron-to-chromium@1.5.155: {}
emoji-regex@8.0.0: {}
@@ -6038,7 +6038,7 @@ snapshots:
jest-worker@27.5.1:
dependencies:
'@types/node': 20.17.46
'@types/node': 20.17.47
merge-stream: 2.0.0
supports-color: 8.1.1
@@ -6690,7 +6690,7 @@ snapshots:
micromark@3.2.0:
dependencies:
'@types/debug': 4.1.12
debug: 4.4.0
debug: 4.4.1
decode-named-character-reference: 1.1.0
micromark-core-commonmark: 1.1.0
micromark-factory-space: 1.1.0
@@ -6712,7 +6712,7 @@ snapshots:
micromark@4.0.2:
dependencies:
'@types/debug': 4.1.12
debug: 4.4.0
debug: 4.4.1
decode-named-character-reference: 1.1.0
devlop: 1.1.0
micromark-core-commonmark: 2.0.3
@@ -6787,7 +6787,7 @@ snapshots:
'@next/env': 14.2.15
'@swc/helpers': 0.5.5
busboy: 1.6.0
caniuse-lite: 1.0.30001717
caniuse-lite: 1.0.30001718
graceful-fs: 4.2.11
postcss: 8.4.31
react: 18.3.1
@@ -6812,7 +6812,7 @@ snapshots:
'@next/env': 14.2.7
'@swc/helpers': 0.5.5
busboy: 1.6.0
caniuse-lite: 1.0.30001717
caniuse-lite: 1.0.30001718
graceful-fs: 4.2.11
postcss: 8.4.31
react: 18.3.1
@@ -6838,7 +6838,7 @@ snapshots:
'@swc/counter': 0.1.3
'@swc/helpers': 0.5.15
busboy: 1.6.0
caniuse-lite: 1.0.30001717
caniuse-lite: 1.0.30001718
postcss: 8.4.31
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
@@ -6957,7 +6957,7 @@ snapshots:
postcss-load-config@4.0.2(postcss@8.5.3):
dependencies:
lilconfig: 3.1.3
yaml: 2.7.1
yaml: 2.8.0
optionalDependencies:
postcss: 8.5.3
@@ -7203,14 +7203,14 @@ snapshots:
vscode-oniguruma: 1.7.0
vscode-textmate: 8.0.0
shiki@3.4.0:
shiki@3.4.1:
dependencies:
'@shikijs/core': 3.4.0
'@shikijs/engine-javascript': 3.4.0
'@shikijs/engine-oniguruma': 3.4.0
'@shikijs/langs': 3.4.0
'@shikijs/themes': 3.4.0
'@shikijs/types': 3.4.0
'@shikijs/core': 3.4.1
'@shikijs/engine-javascript': 3.4.1
'@shikijs/engine-oniguruma': 3.4.1
'@shikijs/langs': 3.4.1
'@shikijs/themes': 3.4.1
'@shikijs/types': 3.4.1
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
@@ -7342,10 +7342,10 @@ snapshots:
jest-worker: 27.5.1
schema-utils: 4.3.2
serialize-javascript: 6.0.2
terser: 5.39.0
terser: 5.39.2
webpack: 5.99.8
terser@5.39.0:
terser@5.39.2:
dependencies:
'@jridgewell/source-map': 0.3.6
acorn: 8.14.1
@@ -7407,7 +7407,7 @@ snapshots:
markdown-it: 14.1.0
minimatch: 9.0.5
typescript: 5.7.3
yaml: 2.7.1
yaml: 2.8.0
typescript@5.7.3: {}
@@ -7605,7 +7605,7 @@ snapshots:
y18n@4.0.3: {}
yaml@2.7.1: {}
yaml@2.8.0: {}
yargs-parser@18.1.3:
dependencies:

41
packages/jazz-cloud-ui/.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View File

@@ -0,0 +1,2 @@
coverage
node_modules

View File

@@ -0,0 +1,19 @@
Copyright 2025, Garden Computing, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,16 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
];
export default eslintConfig;

View File

@@ -0,0 +1,57 @@
{
"name": "jazz-cloud-ui",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"license": "MIT",
"scripts": {
"build": "rm -rf ./dist && tsc --sourceMap --outDir dist",
"dev": " tsc --sourceMap --outDir dist --watch",
"format-and-lint": "biome check .",
"format-and-lint:fix": "biome check . --write"
},
"dependencies": {
"@icons-pack/react-simple-icons": "^12.8.0",
"@radix-ui/react-avatar": "^1.1.9",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-label": "^2.1.6",
"@radix-ui/react-slot": "^1.2.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"input-otp": "^1.4.2",
"jazz-react": "workspace:*",
"jazz-react-auth-betterauth": "workspace:*",
"lucide-react": "^0.510.0",
"next": "^15",
"tailwind-merge": "^3.3.0"
},
"peerDependencies": {
"@icons-pack/react-simple-icons": "^12.8.0",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-label": "^2.1.6",
"@radix-ui/react-slot": "^1.2.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"input-otp": "^1.4.2",
"lucide-react": "^0.510.0",
"next": "^15",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0",
"tailwind-merge": "^3.3.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^16.8 || ^17.0 || ^18.0 || ^19.0",
"@types/react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0",
"eslint": "^9",
"eslint-config-next": "15.3.2",
"tailwindcss": "^4",
"tw-animate-css": "^1.2.5",
"typescript": "catalog:"
}
}

View File

@@ -0,0 +1,371 @@
"use client";
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
import {
FullAuthClient,
SSOProviderType,
useAuth,
} from "jazz-react-auth-betterauth";
import { AlertCircle } from "lucide-react";
import { useRouter } from "next/navigation";
import { useId, useState } from "react";
import { Alert, AlertDescription, AlertTitle } from "../components/ui/alert.js";
import { Button } from "../components/ui/button.js";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "../components/ui/card.js";
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
} from "../components/ui/input-otp.js";
import { Input } from "../components/ui/input.js";
import { Label } from "../components/ui/label.js";
import { SSOButton } from "../components/ui/sso-button.js";
import { cn } from "../lib/utils.js";
import { MagicLinkButton } from "./ui/magic-link-button.js";
import { SendOtpButton } from "./ui/send-otp-button.js";
type LoginFormProps = {
operation: "sign-in" | "sign-up";
supportOtp: boolean;
supportMagicLink: boolean;
providers?: SSOProviderType[];
redirectUrl?: string;
footer?: React.ReactNode;
ssoCallbackUrl?: string;
magicLinkCallbackUrl?: string;
signUpUrl?: string;
signInUrl?: string;
forgotPasswordUrl?: string;
};
export function LoginForm({
className,
operation,
supportOtp = false,
supportMagicLink = false,
providers,
redirectUrl,
footer,
ssoCallbackUrl,
magicLinkCallbackUrl,
signUpUrl,
signInUrl,
forgotPasswordUrl,
...props
}: React.ComponentProps<"div"> & LoginFormProps) {
const router = useRouter();
const auth = useAuth();
const [email, setEmail] = useState<string>("");
const [password, setPassword] = useState<string>("");
const [rememberMe, setRememberMe] = useState(true);
const [otp, setOtp] = useState<string>("");
const [otpStatus, setOtpStatus] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<Error | undefined>(undefined);
const [name, setName] = useState<string>("");
const [confirmPassword, setConfirmPassword] = useState<string>("");
const submitSignIn = async () => {
if (supportOtp && !otpStatus) {
await auth.authClient.signIn.email(
{
email,
password,
rememberMe,
},
{
onSuccess: async () => {
if (redirectUrl) router.push(redirectUrl);
},
onError: (error) => {
setError(error.error);
},
},
);
} else {
const { data, error } = await (
auth.authClient as FullAuthClient
).signIn.emailOtp({
email: email,
otp: otp,
});
const errorMessage = error?.message ?? error?.statusText;
setError(
error
? {
...error,
name: error.statusText,
message:
errorMessage && errorMessage.length > 0
? errorMessage
: "An error occurred",
}
: undefined,
);
if (data && redirectUrl) {
router.push(redirectUrl);
}
}
};
const submitSignUp = async () => {
if (password !== confirmPassword) {
setError(new Error("Passwords do not match"));
setLoading(false);
return;
}
if (supportOtp && !otpStatus) {
await auth.authClient.signUp.email(
{
email,
password,
name,
},
{
onSuccess: async () => {
if (redirectUrl) router.push(redirectUrl);
},
onError: (error) => {
setError(error.error);
},
},
);
} else {
const { data, error } = await (
auth.authClient as FullAuthClient
).signIn.emailOtp({
email: email,
otp: otp,
});
const errorMessage = error?.message ?? error?.statusText;
setError(
error
? {
...error,
name: error.statusText,
message:
errorMessage && errorMessage.length > 0
? errorMessage
: "An error occurred",
}
: undefined,
);
if (data && redirectUrl) {
router.push(redirectUrl);
}
}
};
return (
<div className={cn("flex flex-col gap-6", className)} {...props}>
{otpStatus && (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertTitle>OTP</AlertTitle>
<AlertDescription>
A one-time password has been sent to your email.
</AlertDescription>
</Alert>
)}
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error.message}</AlertDescription>
</Alert>
)}
<Card>
<CardHeader className="text-center">
<CardTitle className="text-xl">
{operation === "sign-in" ? "Welcome back" : "Greetings"}
</CardTitle>
</CardHeader>
<CardContent>
<form
onSubmit={async (e) => {
e.preventDefault();
setLoading(true);
if (operation === "sign-in") {
await submitSignIn();
} else if (operation === "sign-up") {
await submitSignUp();
}
setLoading(false);
}}
>
<div className="grid gap-6">
{(supportOtp || providers || supportMagicLink) && (
<div className="flex flex-col gap-4">
{supportOtp && (
<SendOtpButton
operation={operation}
email={email}
setOtpStatus={setOtpStatus}
setLoading={setLoading}
setError={setError}
/>
)}
{supportMagicLink && (
<MagicLinkButton
callbackURL={magicLinkCallbackUrl}
operation={operation}
email={email}
setLoading={setLoading}
setError={setError}
/>
)}
{providers?.map((x) => {
return (
<SSOButton
key={useId()}
callbackURL={ssoCallbackUrl}
operation={operation}
provider={x}
setLoading={setLoading}
setError={setError}
/>
);
})}
</div>
)}
<div className="after:border-border relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t">
<span className="bg-card text-muted-foreground relative z-10 px-2">
Or continue with
</span>
</div>
<div className="grid gap-6">
{operation === "sign-up" && (
<div className="grid gap-3">
<Label htmlFor="name">Name</Label>
<Input
id="name"
placeholder="Name"
value={name}
disabled={loading}
onChange={(e) => setName(e.target.value)}
required
/>
</div>
)}
<div className="grid gap-3">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="email@example.com"
value={email}
disabled={loading}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="grid gap-3">
<div className="flex items-center">
{!otpStatus && <Label htmlFor="password">Password</Label>}
{supportOtp && otpStatus && (
<Label htmlFor="otp">One-time password</Label>
)}
{!otpStatus &&
forgotPasswordUrl &&
operation === "sign-in" && (
<a
href={forgotPasswordUrl}
className="ml-auto text-sm underline-offset-4 hover:underline"
>
Forgot your password?
</a>
)}
</div>
{!otpStatus && (
<Input
id="password"
type="password"
value={password}
disabled={loading}
onChange={(e) => setPassword(e.target.value)}
required
/>
)}
{supportOtp && otpStatus && (
<InputOTP
id="otp"
maxLength={6}
pattern={REGEXP_ONLY_DIGITS_AND_CHARS}
value={otp}
disabled={loading}
onChange={(value) => setOtp(value)}
>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
)}
{operation === "sign-in" && (
<div className="flex items-center">
<Label htmlFor="remember-me">Remember me</Label>
<Input
id="remember-me"
type="checkbox"
className="w-1/6 ml-auto"
checked={rememberMe}
disabled={loading}
onChange={(e) => setRememberMe(e.target.checked)}
/>
</div>
)}
</div>
{!otpStatus && operation === "sign-up" && (
<div className="grid gap-3">
<Label htmlFor="confirm-password">Confirm password</Label>
<Input
id="confirm-password"
type="password"
value={confirmPassword}
disabled={loading}
onChange={(e) => setConfirmPassword(e.target.value)}
required
/>
</div>
)}
<Button type="submit" className="w-full" disabled={loading}>
{operation === "sign-in" ? "Login" : "Register"}
</Button>
</div>
{operation === "sign-in" && signUpUrl && (
<div className="text-center text-sm">
Don&apos;t have an account?{" "}
<a href={signUpUrl} className="underline underline-offset-4">
Sign up
</a>
</div>
)}
{operation === "sign-up" && signInUrl && (
<div className="text-center text-sm">
Already have an account?{" "}
<a href={signInUrl} className="underline underline-offset-4">
Sign in
</a>
</div>
)}
</div>
</form>
</CardContent>
</Card>
{footer && (
<div className="text-muted-foreground *:[a]:hover:text-primary text-center text-xs text-balance *:[a]:underline *:[a]:underline-offset-4">
{footer}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,146 @@
"use client";
import { useAuth } from "jazz-react-auth-betterauth";
import { AlertCircle } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { Alert, AlertDescription, AlertTitle } from "../components/ui/alert.js";
import { Button } from "../components/ui/button.js";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "../components/ui/card.js";
import { Input } from "../components/ui/input.js";
import { Label } from "../components/ui/label.js";
import { cn } from "../lib/utils.js";
type ResetPasswordFormProps = {
signInUrl?: string;
};
export function ResetPasswordForm({
className,
signInUrl,
...props
}: React.ComponentProps<"div"> & ResetPasswordFormProps) {
const auth = useAuth();
const searchParams = new URLSearchParams(window.location.search);
const token = searchParams.get("token");
const initialError = searchParams.get("error");
const [error, setError] = useState<Error | undefined>(
initialError
? {
name: initialError,
message: initialError,
}
: undefined,
);
const [password, setPassword] = useState<string>("");
const [confirmPassword, setConfirmPassword] = useState<string>("");
const [status, setStatus] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(false);
return (
<div className={cn("flex flex-col gap-6", className)} {...props}>
{status && (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertTitle>Password Reset</AlertTitle>
<AlertDescription>
Your password has been reset. You may now{" "}
{signInUrl ? (
<span>
<Link href={signInUrl}>sign in</Link>.
</span>
) : (
"sign in."
)}
</AlertDescription>
</Alert>
)}
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error.message}</AlertDescription>
</Alert>
)}
<Card>
<CardHeader className="text-center">
<CardTitle className="text-xl">Reset your password</CardTitle>
</CardHeader>
<CardContent>
<form
onSubmit={async (e) => {
e.preventDefault();
setLoading(true);
if (password !== confirmPassword) {
setError(new Error("Passwords do not match"));
setLoading(false);
return;
}
if (!token) {
setError(new Error("No password reset token provided"));
setLoading(false);
return;
}
const { data, error } = await auth.authClient.resetPassword({
newPassword: password,
token,
});
setStatus(data?.status ?? false);
const errorMessage = error?.message ?? error?.statusText;
setError(
error
? {
...error,
name: error.statusText,
message:
errorMessage && errorMessage.length > 0
? errorMessage
: "An error occurred",
}
: undefined,
);
setLoading(false);
}}
>
<div className="grid gap-6">
<div className="grid gap-6">
<div className="grid gap-3">
<div className="flex items-center">
<Label htmlFor="password">Password</Label>
</div>
<Input
id="password"
type="password"
value={password}
disabled={loading}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
<div className="grid gap-3">
<Label htmlFor="confirm-password">Confirm password</Label>
<Input
id="confirm-password"
type="password"
value={confirmPassword}
disabled={loading}
onChange={(e) => setConfirmPassword(e.target.value)}
required
/>
</div>
<Button type="submit" className="w-full" disabled={loading}>
Reset password
</Button>
</div>
</div>
</form>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,65 @@
"use client";
import { SSOProviderType } from "jazz-react-auth-betterauth";
import { AlertCircle } from "lucide-react";
import { useState } from "react";
import { Alert, AlertDescription, AlertTitle } from "../components/ui/alert.js";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "../components/ui/card.js";
import { cn } from "../lib/utils.js";
import { DeleteAccountButton } from "./ui/delete-account-button.js";
import { ProvidersCard } from "./ui/providers-card.js";
type SettingsFormProps = {
providers?: SSOProviderType[];
deleteAccountRedirectUrl?: string;
};
export function SettingsForm({
className,
providers,
deleteAccountRedirectUrl,
...props
}: React.ComponentProps<"div"> & SettingsFormProps) {
const setLoading = useState(false)[1];
const [error, setError] = useState<Error | undefined>(undefined);
return (
<div className={cn("flex flex-col gap-6", className)} {...props}>
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error.message}</AlertDescription>
</Alert>
)}
<Card>
<CardHeader className="text-center">
<CardTitle className="text-xl">Settings</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-6">
<div className="grid gap-6">
<div className="grid gap-3">
<ProvidersCard
providers={providers}
setLoading={setLoading}
setError={setError}
/>
<DeleteAccountButton
redirectUrl={deleteAccountRedirectUrl}
setLoading={setLoading}
setError={setError}
/>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,65 @@
import { type VariantProps, cva } from "class-variance-authority";
import * as React from "react";
import { cn } from "../../lib/utils.js";
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
);
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className,
)}
{...props}
/>
);
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className,
)}
{...props}
/>
);
}
export { Alert, AlertTitle, AlertDescription };

View File

@@ -0,0 +1,49 @@
"use client";
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import * as React from "react";
import { cn } from "../../lib/utils.js";
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className,
)}
{...props}
/>
));
Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
));
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className,
)}
{...props}
/>
));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
export { Avatar, AvatarImage, AvatarFallback };

View File

@@ -0,0 +1,58 @@
import { Slot } from "@radix-ui/react-slot";
import { type VariantProps, cva } from "class-variance-authority";
import * as React from "react";
import { cn } from "../../lib/utils.js";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };

View File

@@ -0,0 +1,91 @@
import * as React from "react";
import { cn } from "../../lib/utils.js";
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className,
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className,
)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className,
)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
);
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
);
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
};

View File

@@ -0,0 +1,31 @@
"use client";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { CheckIcon } from "lucide-react";
import * as React from "react";
import { cn } from "../../lib/utils.js";
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
}
export { Checkbox };

View File

@@ -1,35 +1,46 @@
import { Button } from "@/components/Button";
import { useAccount } from "jazz-react";
import { useAuth } from "jazz-react-auth-betterauth";
import { LogOut } from "lucide-react";
import { useRouter } from "next/navigation";
import { forwardRef } from "react";
import { forwardRef, useCallback } from "react";
import { Button } from "../../components/ui/button.js";
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
children?: React.ReactNode;
src?: InstanceType<typeof Image>["src"];
alt?: InstanceType<typeof Image>["alt"];
callbackURL: string;
redirectUrl?: string;
setLoading: React.Dispatch<React.SetStateAction<boolean>>;
setError: React.Dispatch<React.SetStateAction<Error | undefined>>;
}
export const DeleteAccountButton = forwardRef<HTMLButtonElement, ButtonProps>(
({ callbackURL, setLoading, setError }) => {
const router = useRouter();
({ redirectUrl, setLoading, setError }) => {
const { logOut } = useAccount();
const auth = useAuth();
const router = useRouter();
const signOut = useCallback(() => {
auth.authClient
.signOut()
.catch(console.error)
.finally(() => {
logOut();
if (redirectUrl) router.push(redirectUrl);
});
}, [logOut, router, auth.authClient]);
return (
<Button
variant="danger"
className="relative"
type="button"
variant="destructive"
className="w-full"
onClick={async (e) => {
e.preventDefault();
setLoading(true);
const { error } = await auth.authClient.deleteUser(
{
callbackURL: callbackURL,
callbackURL: undefined,
},
{
onSuccess: () => {
router.replace(callbackURL);
signOut();
},
},
);
@@ -54,3 +65,4 @@ export const DeleteAccountButton = forwardRef<HTMLButtonElement, ButtonProps>(
);
},
);
DeleteAccountButton.displayName = "DeleteAccountButton";

View File

@@ -0,0 +1,200 @@
"use client";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import * as React from "react";
import { cn } from "../../lib/utils.js";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
className,
)}
{...props}
/>
));
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
inset && "pl-8",
className,
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className,
)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
);
};
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};

View File

@@ -0,0 +1,76 @@
"use client";
import { OTPInput, OTPInputContext } from "input-otp";
import { MinusIcon } from "lucide-react";
import * as React from "react";
import { cn } from "../../lib/utils.js";
function InputOTP({
className,
containerClassName,
...props
}: React.ComponentProps<typeof OTPInput> & {
containerClassName?: string;
}) {
return (
<OTPInput
data-slot="input-otp"
containerClassName={cn(
"flex items-center gap-2 has-disabled:opacity-50",
containerClassName,
)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
);
}
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-otp-group"
className={cn("flex items-center", className)}
{...props}
/>
);
}
function InputOTPSlot({
index,
className,
...props
}: React.ComponentProps<"div"> & {
index: number;
}) {
const inputOTPContext = React.useContext(OTPInputContext);
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {};
return (
<div
data-slot="input-otp-slot"
data-active={isActive}
className={cn(
"data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
className,
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
</div>
)}
</div>
);
}
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
return (
<div data-slot="input-otp-separator" role="separator" {...props}>
<MinusIcon />
</div>
);
}
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };

View File

@@ -0,0 +1,20 @@
import * as React from "react";
import { cn } from "../../lib/utils.js";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className,
)}
{...props}
/>
);
}
export { Input };

View File

@@ -0,0 +1,23 @@
"use client";
import * as LabelPrimitive from "@radix-ui/react-label";
import * as React from "react";
import { cn } from "../../lib/utils.js";
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className,
)}
{...props}
/>
);
}
export { Label };

View File

@@ -0,0 +1,40 @@
import { useAccount } from "jazz-react";
import { useAuth } from "jazz-react-auth-betterauth";
import { LogOut } from "lucide-react";
import { useRouter } from "next/navigation";
import { forwardRef, useCallback } from "react";
import { Button } from "../../components/ui/button.js";
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
redirectUrl?: string;
}
export const LogoutButton = forwardRef<HTMLButtonElement, ButtonProps>(
({ redirectUrl }) => {
const { logOut } = useAccount();
const auth = useAuth();
const router = useRouter();
const signOut = useCallback(() => {
auth.authClient
.signOut()
.catch(console.error)
.finally(() => {
logOut();
if (redirectUrl) router.push(redirectUrl);
});
}, [logOut, router, auth.authClient]);
return (
<Button
type="button"
variant="outline"
className="w-full"
onClick={signOut}
>
<LogOut />
Sign out
</Button>
);
},
);
LogoutButton.displayName = "LogoutButton";

View File

@@ -0,0 +1,49 @@
import { FullAuthClient, useAuth } from "jazz-react-auth-betterauth";
import { Link } from "lucide-react";
import { forwardRef } from "react";
import { Button } from "../../components/ui/button.js";
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
operation: "sign-up" | "sign-in";
email: string;
callbackURL?: string;
setLoading: React.Dispatch<React.SetStateAction<boolean>>;
setError: React.Dispatch<React.SetStateAction<Error | undefined>>;
}
export const MagicLinkButton = forwardRef<HTMLButtonElement, ButtonProps>(
({ operation, email, callbackURL, setLoading, setError }) => {
const auth = useAuth();
return (
<Button
type="button"
variant="outline"
className="w-full"
onClick={async (e) => {
e.preventDefault();
setLoading(true);
const { error } = await (
auth.authClient as FullAuthClient
).signIn.magicLink({
email: email,
callbackURL: callbackURL,
});
if (error) {
setError({
...error,
name: error.message ?? error.statusText,
message: error.message ?? error.statusText,
});
}
setLoading(false);
}}
>
<Link />
{operation === "sign-in"
? "Sign in with magic link"
: "Sign up with magic link"}
</Button>
);
},
);
MagicLinkButton.displayName = "MagicLinkButton";

View File

@@ -0,0 +1,65 @@
import {
AccountsType,
SSOProviderType,
useAuth,
} from "jazz-react-auth-betterauth";
import { useEffect, useId, useState } from "react";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "./card.js";
import { SSOButton } from "./sso-button.js";
type ProvidersCardProps = {
providers?: SSOProviderType[];
setLoading: React.Dispatch<React.SetStateAction<boolean>>;
setError: React.Dispatch<React.SetStateAction<Error | undefined>>;
};
export const ProvidersCard = ({
providers,
setLoading,
setError,
}: ProvidersCardProps) => {
const auth = useAuth();
const [accounts, setAccounts] = useState<AccountsType | undefined>(undefined);
useEffect(() => {
return auth.authClient.useSession.subscribe(() => {
auth.authClient.listAccounts().then((x) => setAccounts(x));
});
}, [auth.authClient]);
const linkedProviders =
providers?.filter((x) => accounts?.data?.some((y) => y.provider === x)) ??
[];
return (
<Card>
<CardHeader>
<CardTitle>Single Sign-On Providers</CardTitle>
<CardDescription>
Connect your account to an external authentication provider.
</CardDescription>
</CardHeader>
<CardContent>
{!providers?.length && "No SSO providers configured."}
{providers?.map((x) => {
return (
<SSOButton
key={useId()}
operation={linkedProviders.includes(x) ? "unlink" : "link"}
provider={x}
setLoading={setLoading}
setError={setError}
/>
);
})}
</CardContent>
</Card>
);
};
ProvidersCard.displayName = "ProvidersCard";

View File

@@ -0,0 +1,71 @@
import { FullAuthClient, useAuth } from "jazz-react-auth-betterauth";
import { RectangleEllipsis } from "lucide-react";
import { forwardRef } from "react";
import { Button } from "../../components/ui/button.js";
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
operation: "sign-up" | "sign-in" | "verify" | "reset";
email: string;
setOtpStatus: React.Dispatch<React.SetStateAction<boolean>>;
setLoading: React.Dispatch<React.SetStateAction<boolean>>;
setError: React.Dispatch<React.SetStateAction<Error | undefined>>;
}
export const SendOtpButton = forwardRef<HTMLButtonElement, ButtonProps>(
({ operation, email, setOtpStatus, setLoading, setError }) => {
const auth = useAuth();
const otpType =
(() => {
if (operation === "sign-up" || operation === "sign-in") {
return "sign-in";
} else if (operation === "verify") {
return "email-verification";
} else if (operation === "reset") {
return "forget-password";
}
})() ?? "sign-in";
return (
<Button
type="button"
variant="outline"
className="w-full"
onClick={async (e) => {
e.preventDefault();
setLoading(true);
const { data, error } = await (
auth.authClient as FullAuthClient
).emailOtp.sendVerificationOtp({
email: email,
type: otpType,
});
setOtpStatus(data?.success ?? false);
const errorMessage = error?.message ?? error?.statusText;
setError(
error
? {
...error,
name: error.statusText,
message:
errorMessage && errorMessage.length > 0
? errorMessage
: "An error occurred",
}
: undefined,
);
setLoading(false);
}}
>
<RectangleEllipsis />
{(() => {
if (operation === "sign-in") return "Sign in with one-time password";
if (operation === "sign-up") return "Sign up with one-time password";
if (operation === "verify")
return "Verify account using one-time code";
if (operation === "reset")
return "Reset password using one-time code";
})()}
</Button>
);
},
);
SendOtpButton.displayName = "SendOtpButton";

View File

@@ -1,54 +1,49 @@
import { Button } from "@/components/Button";
import { SSOProviderType, useAuth } from "jazz-react-auth-betterauth";
import { socialProviderNames } from "jazz-react-auth-betterauth";
import { forwardRef } from "react";
import { Button } from "../../components/ui/button.js";
import { ssoIcons } from "../../lib/sso.js";
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
children?: React.ReactNode;
src?: InstanceType<typeof Image>["src"];
alt?: InstanceType<typeof Image>["alt"];
provider: SSOProviderType;
link?: boolean;
operation: "sign-in" | "sign-up" | "link" | "unlink";
accountId?: string;
callbackURL?: string;
setLoading: React.Dispatch<React.SetStateAction<boolean>>;
setError: React.Dispatch<React.SetStateAction<Error | undefined>>;
}
export const SSOButton = forwardRef<HTMLButtonElement, ButtonProps>(
(
{
children,
provider,
link = false,
callbackURL,
setLoading,
setError,
...buttonProps
},
ref,
) => {
({ provider, operation, accountId, callbackURL, setLoading, setError }) => {
const auth = useAuth();
const providerName = socialProviderNames[provider];
const providerIcon = ssoIcons[provider];
return (
<Button
src={`/social/${provider}.svg`}
alt={`${providerName} logo`}
imageClassName="absolute left-3 dark:invert"
variant="secondary"
className="relative"
type="button"
variant="outline"
className="w-full"
onClick={async (e) => {
e.preventDefault();
setLoading(true);
const { error } = await (async () => {
if (link) {
if (operation === "link") {
return await auth.authClient.linkSocial({
provider: provider,
});
} else {
} else if (operation === "sign-in" || operation === "sign-up") {
return await auth.authClient.signIn.social({
provider: provider,
callbackURL: callbackURL,
});
} else {
return await auth.authClient.unlinkAccount({
providerId: provider,
accountId: accountId,
});
}
})();
if (error) {
@@ -60,14 +55,16 @@ export const SSOButton = forwardRef<HTMLButtonElement, ButtonProps>(
}
setLoading(false);
}}
{...buttonProps}
ref={ref}
>
{link
? `Link ${providerName} account`
: `Continue with ${providerName}`}
{children}
{providerIcon}
{(() => {
if (operation === "sign-in") return `Login with ${providerName}`;
if (operation === "sign-up") return `Register with ${providerName}`;
if (operation === "link") return "Link";
if (operation === "unlink") return "Unlink";
})()}
</Button>
);
},
);
SSOButton.displayName = "SSOButton";

View File

@@ -0,0 +1,109 @@
import { useAccount } from "jazz-react";
import { useAuth } from "jazz-react-auth-betterauth";
import { ChevronsUpDown, LogOut, Settings } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { forwardRef, useCallback } from "react";
import { Avatar, AvatarFallback } from "./avatar.js";
import { Button } from "./button.js";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "./dropdown-menu.js";
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
logoutRedirectUrl?: string;
settingsUrl?: string;
}
export const UserButton = forwardRef<HTMLButtonElement, ButtonProps>(
({ logoutRedirectUrl, settingsUrl }) => {
const { logOut } = useAccount();
const auth = useAuth();
const router = useRouter();
const signOut = useCallback(() => {
auth.authClient
.signOut()
.catch(console.error)
.finally(() => {
logOut();
if (logoutRedirectUrl) router.push(logoutRedirectUrl);
});
}, [logOut, router, auth.authClient]);
const avatarFallback = auth.account?.name
.split(" ")
.map((x) => x.charAt(0).toUpperCase())
.join("");
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<Avatar className="h-8 w-8 rounded-lg">
<AvatarFallback className="rounded-lg">
{avatarFallback}
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">
{auth.account?.name}
</span>
<span className="truncate text-xs">{auth.account?.email}</span>
</div>
<ChevronsUpDown className="ml-auto size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg"
// side={isMobile ? "bottom" : "right"}
align="end"
sideOffset={4}
>
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar className="h-8 w-8 rounded-lg">
<AvatarFallback className="rounded-lg">
{avatarFallback}
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">
{auth.account?.name}
</span>
<span className="truncate text-xs">{auth.account?.email}</span>
</div>
</div>
</DropdownMenuLabel>
{settingsUrl && (
<>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<Link href={settingsUrl}>
<DropdownMenuItem>
<Settings />
Settings
</DropdownMenuItem>
</Link>
</DropdownMenuGroup>
</>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={signOut}>
<LogOut />
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
},
);
UserButton.displayName = "UserButton";

View File

@@ -0,0 +1,20 @@
// UI
export * from "./components/ui/alert.js";
export * from "./components/ui/button.js";
export * from "./components/ui/card.js";
export * from "./components/ui/checkbox.js";
export * from "./components/ui/delete-account-button.js";
export * from "./components/ui/input.js";
export * from "./components/ui/input-otp.js";
export * from "./components/ui/label.js";
export * from "./components/ui/logout-button.js";
export * from "./components/ui/magic-link-button.js";
export * from "./components/ui/providers-card.js";
export * from "./components/ui/send-otp-button.js";
export * from "./components/ui/sso-button.js";
export * from "./components/ui/user-button.js";
// Forms
export * from "./components/login-form.js";
export * from "./components/reset-password-form.js";
export * from "./components/settings-form.js";

View File

@@ -0,0 +1,40 @@
import {
SiApple,
SiDiscord,
SiDropbox,
SiFacebook,
SiGithub,
SiGitlab,
SiGoogle,
SiKick,
SiReddit,
SiRoblox,
SiSpotify,
SiTiktok,
SiTwitch,
SiVk,
SiX,
SiZoom,
} from "@icons-pack/react-simple-icons";
import { SSOProviderType } from "jazz-react-auth-betterauth";
export const ssoIcons: Record<SSOProviderType, React.ReactNode> = {
github: <SiGithub />,
google: <SiGoogle />,
apple: <SiApple />,
discord: <SiDiscord />,
facebook: <SiFacebook />,
microsoft: undefined,
twitter: <SiX />,
dropbox: <SiDropbox />,
linkedin: undefined,
gitlab: <SiGitlab />,
kick: <SiKick />,
tiktok: <SiTiktok />,
twitch: <SiTwitch />,
vk: <SiVk />,
zoom: <SiZoom />,
roblox: <SiRoblox />,
reddit: <SiReddit />,
spotify: <SiSpotify />,
};

View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"jsx": "react-jsx",
"declaration": true,
"outDir": "dist",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"allowJs": true,
"incremental": false,
"lib": ["dom", "dom.iterable", "esnext"],
"module": "esnext",
"moduleResolution": "bundler"
},
"include": ["src"]
}

13429
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff