diff options
Diffstat (limited to 'ui/src')
| -rw-r--r-- | ui/src/App.tsx | 39 | ||||
| -rw-r--r-- | ui/src/components/Button.tsx | 20 | ||||
| -rw-r--r-- | ui/src/components/LoginOrRegister.tsx | 341 | ||||
| -rw-r--r-- | ui/src/components/history/Stats.tsx | 9 | ||||
| -rw-r--r-- | ui/src/components/ui/dialog.tsx | 120 | ||||
| -rw-r--r-- | ui/src/state/client.ts | 16 | ||||
| -rw-r--r-- | ui/src/state/models.ts | 16 | ||||
| -rw-r--r-- | ui/src/state/store.ts | 3 |
8 files changed, 546 insertions, 18 deletions
diff --git a/ui/src/App.tsx b/ui/src/App.tsx index ae6ebdb1..54b62c46 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,6 +1,19 @@ import "./App.css"; import { useState, ReactElement } from "react"; +import { useStore } from "@/state/store"; + +import Button, { ButtonStyle } from "@/components/Button"; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; + import { Cog6ToothIcon, HomeIcon, @@ -16,6 +29,7 @@ function classNames(...classes: any) { import Home from "./pages/Home.tsx"; import History from "./pages/History.tsx"; import Dotfiles from "./pages/Dotfiles.tsx"; +import LoginOrRegister from "./components/LoginOrRegister.tsx"; enum Section { Home, @@ -39,6 +53,8 @@ function App() { // I think hashrouter may work, but I'd rather avoiding thinking of them as // pages const [section, setSection] = useState(Section.Home); + const user = useStore((state) => state.user); + console.log(user); const navigation = [ { @@ -96,16 +112,19 @@ function App() { </ul> </li> <li className="mt-auto"> - <a - href="#" - className="group -mx-2 flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-gray-700 hover:bg-gray-50 hover:text-green-600" - > - <Cog6ToothIcon - className="h-6 w-6 shrink-0 text-gray-400 group-hover:text-green-600" - aria-hidden="true" - /> - Settings - </a> + {user && !user.isLoggedIn() && ( + <Dialog> + <DialogTrigger className="w-full"> + <Button + text={"Login or Register"} + style={ButtonStyle.PrimarySmFill} + /> + </DialogTrigger> + <DialogContent> + <LoginOrRegister /> + </DialogContent> + </Dialog> + )} </li> </ul> </nav> 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, +} diff --git a/ui/src/state/client.ts b/ui/src/state/client.ts index f43683c1..5ec0d8a7 100644 --- a/ui/src/state/client.ts +++ b/ui/src/state/client.ts @@ -11,3 +11,19 @@ export async function sessionToken(): Promise<String> { export async function settings(): Promise<Settings> { return await invoke("config"); } + +export async function login( + username: string, + password: string, + key: string, +): Promise<string> { + return await invoke("login", { username, password, key }); +} + +export async function register( + username: string, + email: string, + password: string, +): Promise<string> { + return await invoke("register", { username, email, password }); +} diff --git a/ui/src/state/models.ts b/ui/src/state/models.ts index 57db44ae..c1d97f4b 100644 --- a/ui/src/state/models.ts +++ b/ui/src/state/models.ts @@ -1,12 +1,18 @@ import Database from "@tauri-apps/plugin-sql"; -export interface User { - username: string; +export class User { + username: string | null; + + constructor(username: string) { + this.username = username; + } + + isLoggedIn(): boolean { + return this.username !== "" && this.username !== null; + } } -export const DefaultUser: User = { - username: "", -}; +export const DefaultUser: User = new User(""); export interface HomeInfo { historyCount: number; diff --git a/ui/src/state/store.ts b/ui/src/state/store.ts index 5e2570bb..6746c1fb 100644 --- a/ui/src/state/store.ts +++ b/ui/src/state/store.ts @@ -94,6 +94,7 @@ export const useStore = create<AtuinState>()((set, get) => ({ session = await sessionToken(); } catch (e) { console.log("Not logged in, so not refreshing user"); + set({ user: DefaultUser }); return; } let url = config.sync_address + "/api/v0/me"; @@ -105,7 +106,7 @@ export const useStore = create<AtuinState>()((set, get) => ({ }); let me = await res.json(); - set({ user: me }); + set({ user: new User(me.username) }); }, historyNextPage: (query?: string) => { |
