aboutsummaryrefslogtreecommitdiffstats
path: root/ui/src/components
diff options
context:
space:
mode:
authorEllie Huxtable <ellie@elliehuxtable.com>2024-05-30 12:49:22 +0100
committerGitHub <noreply@github.com>2024-05-30 12:49:22 +0100
commit467f89c104df40904ef4c6b408507e90fe661724 (patch)
treee93697bdfa14ca6b083b0ea02c85d1d0688e0eba /ui/src/components
parentchore(deps): bump rusty_paseto and rusty_paserk (#2054) (diff)
downloadatuin-467f89c104df40904ef4c6b408507e90fe661724.zip
feat(ui): add login/register dialog (#2056)
Diffstat (limited to 'ui/src/components')
-rw-r--r--ui/src/components/Button.tsx20
-rw-r--r--ui/src/components/LoginOrRegister.tsx341
-rw-r--r--ui/src/components/history/Stats.tsx9
-rw-r--r--ui/src/components/ui/dialog.tsx120
4 files changed, 488 insertions, 2 deletions
diff --git a/ui/src/components/Button.tsx b/ui/src/components/Button.tsx
new file mode 100644
index 00000000..5f7e1160
--- /dev/null
+++ b/ui/src/components/Button.tsx
@@ -0,0 +1,20 @@
+export enum ButtonStyle {
+ PrimarySm = "bg-emerald-500 hover:bg-emerald-600",
+ PrimarySmFill = "bg-emerald-500 hover:bg-emerald-600 w-full text-sm",
+}
+
+interface ButtonProps {
+ text: string;
+ style: ButtonStyle;
+}
+
+export default function Button(props: ButtonProps) {
+ return (
+ <button
+ type="button"
+ className={`rounded ${props.style} px-2 py-1 font-semibold text-white shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500`}
+ >
+ {props.text}
+ </button>
+ );
+}
diff --git a/ui/src/components/LoginOrRegister.tsx b/ui/src/components/LoginOrRegister.tsx
new file mode 100644
index 00000000..c13c314c
--- /dev/null
+++ b/ui/src/components/LoginOrRegister.tsx
@@ -0,0 +1,341 @@
+import Logo from "@/assets/logo-light.svg";
+import { useState } from "react";
+
+import { login, register } from "@/state/client";
+import { useStore } from "@/state/store";
+
+interface LoginProps {
+ toggleRegister: () => void;
+}
+
+function Login(props: LoginProps) {
+ const refreshUser = useStore((state) => state.refreshUser);
+ const [errors, setErrors] = useState<string | null>(null);
+
+ const doLogin = async (e: React.FormEvent<HTMLFormElement>) => {
+ e.preventDefault();
+
+ const form = e.currentTarget;
+ const username = form.username.value;
+ const password = form.password.value;
+ const key = form.key.value;
+
+ console.log("Logging in...");
+ try {
+ await login(username, password, key);
+ refreshUser();
+ console.log("Logged in");
+ } catch (e) {
+ console.error(e);
+ setErrors(e);
+ }
+ };
+
+ return (
+ <>
+ <div className="flex min-h-full flex-1 flex-col justify-center px-6 ">
+ <div className="sm:mx-auto sm:w-full sm:max-w-sm">
+ <img className="mx-auto h-10 w-auto" src={Logo} alt="Atuin" />
+
+ <h2 className="mt-5 text-center text-2xl font-bold leading-9 tracking-tight text-gray-900">
+ Sign in to your account
+ </h2>
+
+ <p className="text-sm text-center text-gray-600 mt-4 text-wrap">
+ Backup and sync your data across devices. All data is end-to-end
+ encrypted and stored securely in the cloud.
+ </p>
+ </div>
+
+ <div className="mt-10 sm:mx-auto sm:w-full sm:max-w-sm">
+ <form
+ className="space-y-6"
+ action="#"
+ method="POST"
+ onSubmit={doLogin}
+ >
+ <div>
+ <label
+ htmlFor="username"
+ className="block text-sm font-medium leading-6 text-gray-900"
+ >
+ Username
+ </label>
+ <div className="mt-2">
+ <input
+ id="username"
+ name="username"
+ type="username"
+ autoComplete="off"
+ autoCapitalize="off"
+ autoCorrect="off"
+ spellCheck="false"
+ required
+ className="block w-full rounded-md border-0 px-1.5 py-1.5 outline-none text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-emerald-600 sm:text-sm sm:leading-6"
+ />
+ </div>
+ </div>
+
+ <div>
+ <div className="flex items-center justify-between">
+ <label
+ htmlFor="password"
+ className="block text-sm font-medium leading-6 text-gray-900"
+ >
+ Password
+ </label>
+ <div className="text-sm">
+ {/* You can't right now. Sorry. Validate emails first.
+ <a
+ href="#"
+ className="font-semibold text-emerald-600 hover:text-emerald-500"
+ >
+ Forgot password?
+ </a>
+ */}
+ </div>
+ </div>
+ <div className="mt-2">
+ <input
+ id="password"
+ name="password"
+ type="password"
+ autoCapitalize="off"
+ autoCorrect="off"
+ spellCheck="false"
+ autoComplete="current-password"
+ required
+ className="block w-full rounded-md border-0 px-1.5 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset outline-none focus:ring-emerald-600 sm:text-sm sm:leading-6"
+ />
+ </div>
+ </div>
+
+ <div>
+ <div className="flex items-center justify-between">
+ <label
+ htmlFor="key"
+ className="block text-sm font-medium leading-6 text-gray-900"
+ >
+ <p>Key</p>
+ <p className="text-xs text-gray-500 font-normal">
+ Paste the output of "atuin key" from another machine
+ </p>
+ </label>
+ </div>
+ <div className="mt-2">
+ <input
+ id="key"
+ name="key"
+ autoCapitalize="off"
+ autoCorrect="off"
+ spellCheck="false"
+ autoComplete="off"
+ required
+ className="block w-full rounded-md border-0 px-1.5 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset outline-none focus:ring-emerald-600 sm:text-sm sm:leading-6"
+ />
+ </div>
+ </div>
+
+ <div>
+ <button
+ type="submit"
+ className="flex w-full justify-center rounded-md bg-emerald-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-emerald-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-600"
+ >
+ Sign in
+ </button>
+ </div>
+ </form>
+
+ {errors && (
+ <p className="mt-4 text-center text-sm text-red-500">{errors}</p>
+ )}
+
+ <p className="mt-10 text-center text-sm text-gray-500">
+ Not a member?{" "}
+ <a
+ href="#"
+ className="font-semibold leading-6 text-emerald-600 hover:text-emerald-500"
+ onClick={(e) => {
+ e.preventDefault();
+ props.toggleRegister();
+ }}
+ >
+ Register
+ </a>
+ </p>
+ </div>
+ </div>
+ </>
+ );
+}
+
+interface RegisterProps {
+ toggleLogin: () => void;
+}
+
+function Register(props: RegisterProps) {
+ const refreshUser = useStore((state) => state.refreshUser);
+ const [errors, setErrors] = useState<string | null>(null);
+
+ const doRegister = async (e: React.FormEvent<HTMLFormElement>) => {
+ e.preventDefault();
+
+ const form = e.currentTarget;
+ const username = form.username.value;
+ const email = form.email.value;
+ const password = form.password.value;
+
+ console.log("Logging in...");
+ try {
+ await register(username, email, password);
+ refreshUser();
+ console.log("Logged in");
+ } catch (e) {
+ console.error(e);
+ setErrors(e);
+ }
+ };
+
+ return (
+ <>
+ <div className="flex min-h-full flex-1 flex-col justify-center px-6 ">
+ <div className="sm:mx-auto sm:w-full sm:max-w-sm">
+ <img className="mx-auto h-10 w-auto" src={Logo} alt="Atuin" />
+
+ <h2 className="mt-5 text-center text-2xl font-bold leading-9 tracking-tight text-gray-900">
+ Register for an account
+ </h2>
+
+ <p className="text-sm text-center text-gray-600 mt-4 text-wrap">
+ Backup and sync your data across devices. All data is end-to-end
+ encrypted and stored securely in the cloud.
+ </p>
+ </div>
+
+ <div className="mt-10 sm:mx-auto sm:w-full sm:max-w-sm">
+ <form
+ className="space-y-6"
+ action="#"
+ method="POST"
+ onSubmit={doRegister}
+ >
+ <div>
+ <label
+ htmlFor="username"
+ className="block text-sm font-medium leading-6 text-gray-900"
+ >
+ Username
+ </label>
+ <div className="mt-2">
+ <input
+ id="username"
+ name="username"
+ type="username"
+ autoComplete="off"
+ autoCapitalize="off"
+ autoCorrect="off"
+ spellCheck="false"
+ required
+ className="block w-full rounded-md border-0 px-1.5 py-1.5 outline-none text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-emerald-600 sm:text-sm sm:leading-6"
+ />
+ </div>
+ </div>
+
+ <div>
+ <label
+ htmlFor="email"
+ className="block text-sm font-medium leading-6 text-gray-900"
+ >
+ Email
+ </label>
+ <div className="mt-2">
+ <input
+ id="email"
+ name="email"
+ type="email"
+ autoComplete="email"
+ autoCapitalize="off"
+ autoCorrect="off"
+ spellCheck="false"
+ required
+ className="block w-full rounded-md border-0 px-1.5 py-1.5 outline-none text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-emerald-600 sm:text-sm sm:leading-6"
+ />
+ </div>
+ </div>
+
+ <div>
+ <div className="flex items-center justify-between">
+ <label
+ htmlFor="password"
+ className="block text-sm font-medium leading-6 text-gray-900"
+ >
+ Password
+ </label>
+ <div className="text-sm">
+ {/* You can't right now. Sorry. Validate emails first.
+ <a
+ href="#"
+ className="font-semibold text-emerald-600 hover:text-emerald-500"
+ >
+ Forgot password?
+ </a>
+ */}
+ </div>
+ </div>
+ <div className="mt-2">
+ <input
+ id="password"
+ name="password"
+ type="password"
+ autoCapitalize="off"
+ autoCorrect="off"
+ spellCheck="false"
+ autoComplete="current-password"
+ required
+ className="block w-full rounded-md border-0 px-1.5 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset outline-none focus:ring-emerald-600 sm:text-sm sm:leading-6"
+ />
+ </div>
+ </div>
+
+ <div>
+ <button
+ type="submit"
+ className="flex w-full justify-center rounded-md bg-emerald-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-emerald-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-600"
+ >
+ Register
+ </button>
+ </div>
+ </form>
+
+ {errors && (
+ <p className="mt-4 text-center text-sm text-red-500">{errors}</p>
+ )}
+
+ <p className="mt-10 text-center text-sm text-gray-500">
+ Already have an account?{" "}
+ <a
+ href="#"
+ className="font-semibold leading-6 text-emerald-600 hover:text-emerald-500"
+ onClick={(e) => {
+ e.preventDefault();
+ props.toggleLogin();
+ }}
+ >
+ Login
+ </a>
+ </p>
+ </div>
+ </div>
+ </>
+ );
+}
+
+export default function LoginOrRegister() {
+ let [login, setLogin] = useState<boolean>(false);
+
+ if (login) {
+ return <Login toggleRegister={() => setLogin(false)} />;
+ }
+
+ return <Register toggleLogin={() => setLogin(true)} />;
+}
diff --git a/ui/src/components/history/Stats.tsx b/ui/src/components/history/Stats.tsx
index 9e2c9a64..f399eaf0 100644
--- a/ui/src/components/history/Stats.tsx
+++ b/ui/src/components/history/Stats.tsx
@@ -13,8 +13,13 @@ import {
function renderLoading() {
return (
- <div className="flex items-center justify-center h-full">
- <PacmanLoader color="#26bd65" />
+ <div className="flex flex-col items-center justify-center h-full ">
+ <div>
+ <PacmanLoader color="#26bd65" />
+ </div>
+ <div className="block mt-4">
+ <p>Crunching the latest numbers...</p>
+ </div>
</div>
);
}
diff --git a/ui/src/components/ui/dialog.tsx b/ui/src/components/ui/dialog.tsx
new file mode 100644
index 00000000..c23630eb
--- /dev/null
+++ b/ui/src/components/ui/dialog.tsx
@@ -0,0 +1,120 @@
+import * as React from "react"
+import * as DialogPrimitive from "@radix-ui/react-dialog"
+import { X } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const Dialog = DialogPrimitive.Root
+
+const DialogTrigger = DialogPrimitive.Trigger
+
+const DialogPortal = DialogPrimitive.Portal
+
+const DialogClose = DialogPrimitive.Close
+
+const DialogOverlay = React.forwardRef<
+ React.ElementRef<typeof DialogPrimitive.Overlay>,
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
+>(({ className, ...props }, ref) => (
+ <DialogPrimitive.Overlay
+ ref={ref}
+ className={cn(
+ "fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
+ className
+ )}
+ {...props}
+ />
+))
+DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
+
+const DialogContent = React.forwardRef<
+ React.ElementRef<typeof DialogPrimitive.Content>,
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
+>(({ className, children, ...props }, ref) => (
+ <DialogPortal>
+ <DialogOverlay />
+ <DialogPrimitive.Content
+ ref={ref}
+ className={cn(
+ "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
+ className
+ )}
+ {...props}
+ >
+ {children}
+ <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
+ <X className="h-4 w-4" />
+ <span className="sr-only">Close</span>
+ </DialogPrimitive.Close>
+ </DialogPrimitive.Content>
+ </DialogPortal>
+))
+DialogContent.displayName = DialogPrimitive.Content.displayName
+
+const DialogHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes<HTMLDivElement>) => (
+ <div
+ className={cn(
+ "flex flex-col space-y-1.5 text-center sm:text-left",
+ className
+ )}
+ {...props}
+ />
+)
+DialogHeader.displayName = "DialogHeader"
+
+const DialogFooter = ({
+ className,
+ ...props
+}: React.HTMLAttributes<HTMLDivElement>) => (
+ <div
+ className={cn(
+ "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
+ className
+ )}
+ {...props}
+ />
+)
+DialogFooter.displayName = "DialogFooter"
+
+const DialogTitle = React.forwardRef<
+ React.ElementRef<typeof DialogPrimitive.Title>,
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
+>(({ className, ...props }, ref) => (
+ <DialogPrimitive.Title
+ ref={ref}
+ className={cn(
+ "text-lg font-semibold leading-none tracking-tight",
+ className
+ )}
+ {...props}
+ />
+))
+DialogTitle.displayName = DialogPrimitive.Title.displayName
+
+const DialogDescription = React.forwardRef<
+ React.ElementRef<typeof DialogPrimitive.Description>,
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
+>(({ className, ...props }, ref) => (
+ <DialogPrimitive.Description
+ ref={ref}
+ className={cn("text-sm text-muted-foreground", className)}
+ {...props}
+ />
+))
+DialogDescription.displayName = DialogPrimitive.Description.displayName
+
+export {
+ Dialog,
+ DialogPortal,
+ DialogOverlay,
+ DialogClose,
+ DialogTrigger,
+ DialogContent,
+ DialogHeader,
+ DialogFooter,
+ DialogTitle,
+ DialogDescription,
+}