Merge branch 'develop' of git.lumeweb.com:LumeWeb/portal-dashboard into develop

This commit is contained in:
Juan Di Toro 2024-03-20 20:05:13 +01:00
commit 43a0ab0b29
17 changed files with 966 additions and 383 deletions

View File

@ -1,4 +1,4 @@
import { useState } from "react";
import { useMemo} from "react";
import { BaseRecord } from "@refinedev/core";
import { useTable } from "@refinedev/react-table";
import {
@ -14,6 +14,7 @@ import {
TableHeader,
TableRow,
} from "./ui/table"
import { Skeleton } from "./ui/skeleton";
import { DataTablePagination } from "./table-pagination"
interface DataTableProps<TData extends BaseRecord = BaseRecord, TValue = unknown> {
@ -21,22 +22,31 @@ interface DataTableProps<TData extends BaseRecord = BaseRecord, TValue = unknown
}
export function DataTable<TData extends BaseRecord, TValue>({
columns
columns,
}: DataTableProps<TData, TValue>) {
const [hoveredRowId, setHoveredRowId] = useState<string>("");
const table = useTable({
columns,
meta: {
hoveredRowId,
},
refineCoreProps: {
resource: "files"
}
})
const loadingRows = useMemo(() => Array(4).fill({}).map(() => ({
getIsSelected: () => false,
getVisibleCells: () => columns.map(() => ({
column: {
columnDef: {
cell: <Skeleton className="h-4 w-full bg-primary-1/30" />,
}
},
getContext: () => null
})),
})), [])
const rows = table.refineCore.tableQueryResult.isLoading ? loadingRows : table.getRowModel().rows
return (
<div className="rounded-lg">
<>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
@ -57,15 +67,12 @@ export function DataTable<TData extends BaseRecord, TValue>({
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
{rows.length ? (
rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
onMouseEnter={() => {
console.log(hoveredRowId, row.id);
setHoveredRowId(row.id)
}}
className="group"
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
@ -84,6 +91,6 @@ export function DataTable<TData extends BaseRecord, TValue>({
</TableBody>
</Table>
<DataTablePagination table={table} />
</div>
</>
)
}

View File

@ -3,16 +3,21 @@ import { Avatar } from "./ui/avatar";
import { Button } from "./ui/button";
import { EditIcon, FingerPrintIcon } from "./icons";
const ManagementCardAvatar = ({ src }: { src?: string }) => {
const ManagementCardAvatar = ({ src, button, onClick }: { src?: string; button?: React.ReactNode; onClick?: () => void }) => {
return (
<div className="flex justify-center">
<div className="relative w-fit h-fit">
<Avatar className="border-2 border-ring h-28 w-28" />
<Button
variant="outline"
className="absolute bottom-0 right-0 z-50 flex items-center w-10 h-10 p-0 border-white rounded-full justiyf-center hover:bg-secondary-2">
<EditIcon />
</Button>
{!button
? <Button
onClick={onClick}
variant="outline"
className="absolute bottom-0 right-0 z-50 flex items-center w-10 h-10 p-0 border-white rounded-full justiyf-center hover:bg-secondary-2">
<EditIcon />
</Button>
: button
}
</div>
</div>
);

View File

@ -0,0 +1,15 @@
import { cn } from "~/utils"
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-primary/10", className)}
{...props}
/>
)
}
export { Skeleton }

127
app/components/ui/toast.tsx Normal file
View File

@ -0,0 +1,127 @@
import * as React from "react"
import { Cross2Icon } from "@radix-ui/react-icons"
import * as ToastPrimitives from "@radix-ui/react-toast"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "~/utils"
const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className
)}
{...props}
/>
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants> & { cancelMutation?: () => void }
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
)
})
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className
)}
{...props}
/>
))
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className
)}
toast-close=""
{...props}
>
<Cross2Icon className="h-4 w-4" />
</ToastPrimitives.Close>
))
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold [&+div]:text-xs", className)}
{...props}
/>
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastActionElement = React.ReactElement<typeof ToastAction>
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
}

View File

@ -0,0 +1,36 @@
import {
Toast,
ToastAction,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "~/components/ui/toast"
import { useToast } from "~/components/ui/use-toast"
export function Toaster() {
const { toasts } = useToast()
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, cancelMutation, ...props }) {
const undoButton = cancelMutation ? <ToastAction altText="Undo" onClick={cancelMutation}>Undo</ToastAction> : undefined
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
{undoButton}
<ToastClose />
</Toast>
)
})}
<ToastViewport />
</ToastProvider>
)
}

View File

@ -0,0 +1,192 @@
// Inspired by react-hot-toast library
import * as React from "react"
import type {
ToastActionElement,
ToastProps,
} from "~/components/ui/toast"
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
type ToasterToast = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: ToastActionElement
}
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const
let count = 0
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER
return count.toString()
}
type ActionType = typeof actionTypes
type Action =
| {
type: ActionType["ADD_TOAST"]
toast: ToasterToast
}
| {
type: ActionType["UPDATE_TOAST"]
toast: Partial<ToasterToast>
}
| {
type: ActionType["DISMISS_TOAST"]
toastId?: ToasterToast["id"]
}
| {
type: ActionType["REMOVE_TOAST"]
toastId?: ToasterToast["id"]
}
interface State {
toasts: ToasterToast[]
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
toastTimeouts.set(toastId, timeout)
}
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
}
case "DISMISS_TOAST": {
const { toastId } = action
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId)
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
),
}
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
}
}
}
const listeners: Array<(state: State) => void> = []
let memoryState: State = { toasts: [] }
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
listeners.forEach((listener) => {
listener(memoryState)
})
}
type Toast = Omit<ToasterToast, "id">
function toast({ ...props }: Toast) {
const id = genId()
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
},
},
})
return {
id: id,
dismiss,
update,
}
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState)
React.useEffect(() => {
listeners.push(setState)
return () => {
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1)
}
}
}, [state])
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
}
}
export { useToast, toast }

View File

@ -10,10 +10,16 @@ type AccountData = AccountParams;
export const accountProvider: SdkProvider = {
getList: () => {
throw Error("Not Implemented")
console.log("Not implemented");
return Promise.resolve({
data: [],
});
},
getOne: () => {
throw Error("Not Implemented")
console.log("Not implemented");
return Promise.resolve({
data: {},
});
},
// @ts-ignore
async update<TVariables extends AccountParams = AccountParams>(
@ -44,10 +50,16 @@ export const accountProvider: SdkProvider = {
};
},
create: () => {
throw Error("Not Implemented")
console.log("Not implemented");
return Promise.resolve({
data: {},
});
},
deleteOne: () => {
throw Error("Not Implemented")
console.log("Not implemented");
return Promise.resolve({
data: {},
});
},
getApiUrl: () => "",
}

View File

@ -4,12 +4,15 @@ import type {
AuthActionResponse,
CheckResponse,
IdentityResponse,
OnErrorResponse
OnErrorResponse,
SuccessNotificationResponse
// @ts-ignore
} from "@refinedev/core/dist/interfaces/bindings/auth"
import {Sdk} from "@lumeweb/portal-sdk";
import type {AccountInfoResponse} from "@lumeweb/portal-sdk";
;
export type AuthFormRequest = {
email: string;
password: string;
@ -31,7 +34,7 @@ export type Identity = {
email: string;
}
export interface UpdatePasswordFormRequest extends UpdatePasswordFormTypes{
export interface UpdatePasswordFormRequest extends UpdatePasswordFormTypes {
currentPassword: string;
}
@ -43,6 +46,39 @@ export const createPortalAuthProvider = (sdk: Sdk): AuthProvider => {
}
};
type ResponseResult = {
ret: boolean | Error;
successNotification?: SuccessNotificationResponse;
redirectToSuccess?: string;
redirectToError?: string;
successCb?: () => void;
}
const handleResponse = (result: ResponseResult): AuthActionResponse => {
if (result.ret) {
if (result.ret instanceof Error) {
return {
success: false,
error: result.ret,
redirectTo: result.redirectToError
}
}
result.successCb?.();
return {
success: true,
successNotification: result.successNotification,
redirectTo: result.redirectToSuccess,
}
}
return {
success: false,
redirectTo: result.redirectToError
}
}
return {
async login(params: AuthFormRequest): Promise<AuthActionResponse> {
const ret = await sdk.account().login({
@ -50,35 +86,26 @@ export const createPortalAuthProvider = (sdk: Sdk): AuthProvider => {
password: params.password,
});
let redirectTo: string | undefined;
return handleResponse({
ret, redirectToSuccess: "/dashboard", redirectToError: "/login", successCb: () => {
sdk.setAuthToken(sdk.account().jwtToken);
}, successNotification: {
message: "Login Successful",
description: "You have successfully logged in."
if (ret) {
redirectTo = params.redirectTo;
if (!redirectTo) {
redirectTo = ret ? "/dashboard" : "/login";
}
sdk.setAuthToken(sdk.account().jwtToken);
}
return {
success: ret,
redirectTo,
};
});
},
async logout(params: any): Promise<AuthActionResponse> {
let ret = await sdk.account().logout();
return {success: ret, redirectTo: "/login"};
return handleResponse({ret, redirectToSuccess: "/login"});
},
async check(params?: any): Promise<CheckResponse> {
const ret = await sdk.account().ping();
if (ret) {
maybeSetupAuth();
}
return {authenticated: ret, redirectTo: ret ? undefined : "/login"};
return handleResponse({ret, redirectToError: "/login", successCb: maybeSetupAuth});
},
async onError(error: any): Promise<OnErrorResponse> {
@ -92,7 +119,12 @@ export const createPortalAuthProvider = (sdk: Sdk): AuthProvider => {
first_name: params.firstName,
last_name: params.lastName,
});
return {success: ret, redirectTo: ret ? "/dashboard" : undefined};
return handleResponse({
ret, redirectToSuccess: "/login", successNotification: {
message: "Registration Successful",
description: "You have successfully registered. Please check your email to verify your account.",
}
});
},
async forgotPassword(params: any): Promise<AuthActionResponse> {
@ -103,22 +135,12 @@ export const createPortalAuthProvider = (sdk: Sdk): AuthProvider => {
maybeSetupAuth();
const ret = await sdk.account().updatePassword(params.currentPassword, params.password as string);
if (ret) {
if (ret instanceof Error) {
return {
success: false,
error: ret
}
return handleResponse({
ret, successNotification: {
message: "Password Updated",
description: "Your password has been updated successfully.",
}
return {
success: true
}
} else {
return {
success: false
}
}
});
},
async getPermissions(params?: Record<string, any>): Promise<AuthActionResponse> {

View File

@ -1,11 +1,39 @@
import type { DataProvider } from "@refinedev/core";
import {SdkProvider} from "~/data/sdk-provider.js";
import { SdkProvider } from "~/data/sdk-provider.js";
export const fileProvider: SdkProvider = {
getList: () => { throw Error("Not Implemented") },
getOne: () => { throw Error("Not Implemented") },
update: () => { throw Error("Not Implemented") },
create: () => { throw Error("Not Implemented") },
deleteOne: () => { throw Error("Not Implemented") },
export const fileProvider = {
getList: () => {
console.log("Not implemented");
return Promise.resolve({
data: [],
total: 0,
});
},
getOne: () => {
console.log("Not implemented");
return Promise.resolve({
data: {
id: 1
},
});
},
update: () => {
console.log("Not implemented");
return Promise.resolve({
data: {},
});
},
create: () => {
console.log("Not implemented");
return Promise.resolve({
data: {},
});
},
deleteOne: () => {
console.log("Not implemented");
return Promise.resolve({
data: {},
});
},
getApiUrl: () => "",
}
} satisfies SdkProvider;

View File

@ -0,0 +1,40 @@
import type {
NotificationProvider,
OpenNotificationParams,
} from "@refinedev/core";
import type { ToastActionElement } from "~/components/ui/toast";
import { toast } from "~/components/ui/use-toast";
interface Provider extends Omit<NotificationProvider, "open"> {
open: (
params: Omit<OpenNotificationParams, "type"> & {
action?: ToastActionElement;
type: "default" | "destructive";
},
) => void;
}
export const notificationProvider = () => {
return {
open: ({
key,
message,
description,
undoableTimeout,
cancelMutation,
action,
type,
}) => {
toast({
variant: type,
key,
title: message,
description,
duration: undoableTimeout,
action,
cancelMutation,
});
},
close: () => {},
} satisfies Provider;
};

View File

@ -7,7 +7,9 @@ import type {LinksFunction} from "@remix-run/node";
import '@fontsource-variable/manrope';
import {Refine} from "@refinedev/core";
import routerProvider from "@refinedev/remix-router";
import {SdkContextProvider} from "~/components/lib/sdk-context.js";
import { notificationProvider } from "~/data/notification-provider";
import {SdkContextProvider} from "~/components/lib/sdk-context";
import { Toaster } from "~/components/ui/toaster";
import {getProviders} from "~/data/providers.js";
import {Sdk} from "@lumeweb/portal-sdk";
import resources from "~/data/resources.js";
@ -27,6 +29,7 @@ export function Layout({children}: { children: React.ReactNode }) {
</head>
<body>
{children}
<Toaster />
<ScrollRestoration/>
<Scripts/>
</body>
@ -41,6 +44,7 @@ export default function App() {
<Refine
authProvider={providers.auth}
routerProvider={routerProvider}
notificationProvider={notificationProvider}
dataProvider={providers.default}
resources={resources}
options={{disableTelemetry: true}}

View File

@ -1,24 +1,12 @@
import {useGo, useIsAuthenticated} from "@refinedev/core";
import {useEffect} from "react";
import {Authenticated} from "@refinedev/core";
import {Navigate} from "@remix-run/react";
export default function Index() {
const {isLoading, data} = useIsAuthenticated();
const go = useGo();
useEffect(() => {
if (!isLoading) {
if (data?.authenticated) {
go({to: "/dashboard", type: "replace"});
} else {
go({to: "/login", type: "replace"});
}
}
}, [isLoading, data]);
if (isLoading) {
return <>Checking Login Status</> || null;
}
return (<>Redirecting</>) || null;
return (
<Authenticated v3LegacyAuthProviderCompatible key={"index"} loading={
<>Checking Login Status</>
}>
<Navigate to="/dashboard" replace/>
</Authenticated>
)
}

View File

@ -1,16 +1,27 @@
import { getFormProps, useForm } from "@conform-to/react";
import { getZodConstraint, parseWithZod } from "@conform-to/zod";
import { DialogClose } from "@radix-ui/react-dialog";
import { Cross2Icon } from "@radix-ui/react-icons";
import {
BaseKey,
useGetIdentity,
useUpdate,
useUpdatePassword,
Authenticated,
BaseKey,
useGetIdentity,
useUpdate,
useUpdatePassword,
} from "@refinedev/core";
import { useState } from "react";
import { useMemo, useState } from "react";
import { z } from "zod";
import { Field } from "~/components/forms";
import { GeneralLayout } from "~/components/general-layout";
import { AddIcon, CloudIcon, CrownIcon } from "~/components/icons";
import {
AddIcon,
CloudCheckIcon,
CloudIcon,
CloudUploadIcon,
CrownIcon,
EditIcon,
} from "~/components/icons";
import { useUppy } from "~/components/lib/uppy";
import {
ManagementCard,
ManagementCardAvatar,
@ -24,6 +35,7 @@ import {
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/components/ui/dialog";
import { Input } from "~/components/ui/input";
import { UsageCard } from "~/components/usage-card";
@ -38,147 +50,171 @@ export default function MyAccount() {
changeEmail: false,
changePassword: false,
setupTwoFactor: false,
changeAvatar: false,
});
return (
<GeneralLayout>
<Authenticated key="account" v3LegacyAuthProviderCompatible>
<GeneralLayout>
<h1 className="text-lg font-bold mb-4">My Account</h1>
<UsageCard
label="Usage"
currentUsage={2}
monthlyUsage={10}
icon={<CloudIcon className="text-ring" />}
button={
<Button variant="accent" className="gap-x-2 h-12">
<AddIcon />
Upgrade to Premium
</Button>
}
/>
<h2 className="font-bold my-8">Account Management</h2>
<div className="grid grid-cols-3 gap-x-8">
<ManagementCard>
<ManagementCardAvatar />
</ManagementCard>
<ManagementCard>
<ManagementCardTitle>Email Address</ManagementCardTitle>
<ManagementCardContent className="text-ring font-semibold">
{identity?.email}
</ManagementCardContent>
<ManagementCardFooter>
<Button
className="h-12 gap-x-2"
onClick={() => setModal({ ...openModal, changeEmail: true })}>
<AddIcon />
Change Email Address
</Button>
</ManagementCardFooter>
</ManagementCard>
<ManagementCard>
<ManagementCardTitle>Account Type</ManagementCardTitle>
<ManagementCardContent className="text-ring font-semibold flex gap-x-2">
Lite Premium Account
<CrownIcon />
</ManagementCardContent>
<ManagementCardFooter>
<Button className="h-12 gap-x-2">
<Dialog
onOpenChange={(open) => {
if (!open) {
setModal({
changeEmail: false,
changePassword: false,
setupTwoFactor: false,
changeAvatar: false,
});
}
}}>
<UsageCard
label="Usage"
currentUsage={2}
monthlyUsage={10}
icon={<CloudIcon className="text-ring" />}
button={
<Button variant="accent" className="gap-x-2 h-12">
<AddIcon />
Upgrade to Premium
</Button>
</ManagementCardFooter>
</ManagementCard>
</div>
<h2 className="font-bold my-8">Security</h2>
<div className="grid grid-cols-3 gap-x-8">
<ManagementCard>
<ManagementCardTitle>Password</ManagementCardTitle>
<ManagementCardContent>
<PasswordDots className="mt-6" />
</ManagementCardContent>
<ManagementCardFooter>
<Button
className="h-12 gap-x-2"
onClick={() => setModal({ ...openModal, changePassword: true })}>
<AddIcon />
Change Password
</Button>
</ManagementCardFooter>
</ManagementCard>
<ManagementCard>
<ManagementCardTitle>Two-Factor Authentication</ManagementCardTitle>
<ManagementCardContent>
Improve security by enabling 2FA.
</ManagementCardContent>
<ManagementCardFooter>
<Button
className="h-12 gap-x-2"
onClick={() => setModal({ ...openModal, setupTwoFactor: true })}>
<AddIcon />
Enable Two-Factor Authorization
</Button>
</ManagementCardFooter>
</ManagementCard>
</div>
<h2 className="font-bold my-8">More</h2>
<div className="grid grid-cols-3 gap-x-8">
<ManagementCard variant="accent">
<ManagementCardTitle>Invite a Friend</ManagementCardTitle>
<ManagementCardContent>
Get 1 GB per friend invited for free (max 5 GB).
</ManagementCardContent>
<ManagementCardFooter>
<Button variant="accent" className="h-12 gap-x-2">
<AddIcon />
Send Invitation
</Button>
</ManagementCardFooter>
</ManagementCard>
<ManagementCard>
<ManagementCardTitle>Read our Resources</ManagementCardTitle>
<ManagementCardContent>
Navigate helpful articles or get assistance.
</ManagementCardContent>
<ManagementCardFooter>
<Button className="h-12 gap-x-2">
<AddIcon />
Open Support Centre
</Button>
</ManagementCardFooter>
</ManagementCard>
<ManagementCard>
<ManagementCardTitle>Delete Account</ManagementCardTitle>
<ManagementCardContent>
Once initiated, this action cannot be undone.
</ManagementCardContent>
<ManagementCardFooter>
<Button className="h-12 gap-x-2" variant="destructive">
<AddIcon />
Delete my Account
</Button>
</ManagementCardFooter>
</ManagementCard>
</div>
{/* Dialogs must be near to body as possible to open the modal, otherwise will be restricted to parent height-width */}
<ChangeEmailForm
open={openModal.changeEmail}
setOpen={(value: boolean) =>
setModal({ ...openModal, changeEmail: value })
}
currentValue={identity?.email || ""}
/>
<ChangePasswordForm
open={openModal.changePassword}
setOpen={(value: boolean) =>
setModal({ ...openModal, changePassword: value })
}
/>
<SetupTwoFactorDialog
open={openModal.setupTwoFactor}
setOpen={(value: boolean) =>
setModal({ ...openModal, setupTwoFactor: value })
}
/>
}
/>
<h2 className="font-bold my-8">Account Management</h2>
<div className="grid grid-cols-3 gap-x-8">
<ManagementCard>
<ManagementCardAvatar
button={
<DialogTrigger className="absolute bottom-0 right-0 z-50">
<Button
onClick={() => setModal({ ...openModal, changeAvatar: true })}
variant="outline"
className=" flex items-center w-10 h-10 p-0 border-white rounded-full justiyf-center hover:bg-secondary-2">
<EditIcon />
</Button>
</DialogTrigger>
}
/>
</ManagementCard>
<ManagementCard>
<ManagementCardTitle>Email Address</ManagementCardTitle>
<ManagementCardContent className="text-ring font-semibold">
{identity?.email}
</ManagementCardContent>
<ManagementCardFooter>
<DialogTrigger>
<Button
className="h-12 gap-x-2"
onClick={() => setModal({ ...openModal, changeEmail: true })}>
<AddIcon />
Change Email Address
</Button>
</DialogTrigger>
</ManagementCardFooter>
</ManagementCard>
<ManagementCard>
<ManagementCardTitle>Account Type</ManagementCardTitle>
<ManagementCardContent className="text-ring font-semibold flex gap-x-2">
Lite Premium Account
<CrownIcon />
</ManagementCardContent>
<ManagementCardFooter>
<Button className="h-12 gap-x-2">
<AddIcon />
Upgrade to Premium
</Button>
</ManagementCardFooter>
</ManagementCard>
</div>
<h2 className="font-bold my-8">Security</h2>
<div className="grid grid-cols-3 gap-x-8">
<ManagementCard>
<ManagementCardTitle>Password</ManagementCardTitle>
<ManagementCardContent>
<PasswordDots className="mt-6" />
</ManagementCardContent>
<ManagementCardFooter>
<DialogTrigger>
<Button
className="h-12 gap-x-2"
onClick={() =>
setModal({ ...openModal, changePassword: true })
}>
<AddIcon />
Change Password
</Button>
</DialogTrigger>
</ManagementCardFooter>
</ManagementCard>
<ManagementCard>
<ManagementCardTitle>Two-Factor Authentication</ManagementCardTitle>
<ManagementCardContent>
Improve security by enabling 2FA.
</ManagementCardContent>
<ManagementCardFooter>
<DialogTrigger>
<Button
className="h-12 gap-x-2"
onClick={() =>
setModal({ ...openModal, setupTwoFactor: true })
}>
<AddIcon />
Enable Two-Factor Authorization
</Button>
</DialogTrigger>
</ManagementCardFooter>
</ManagementCard>
</div>
<h2 className="font-bold my-8">More</h2>
<div className="grid grid-cols-3 gap-x-8">
<ManagementCard variant="accent">
<ManagementCardTitle>Invite a Friend</ManagementCardTitle>
<ManagementCardContent>
Get 1 GB per friend invited for free (max 5 GB).
</ManagementCardContent>
<ManagementCardFooter>
<Button variant="accent" className="h-12 gap-x-2">
<AddIcon />
Send Invitation
</Button>
</ManagementCardFooter>
</ManagementCard>
<ManagementCard>
<ManagementCardTitle>Read our Resources</ManagementCardTitle>
<ManagementCardContent>
Navigate helpful articles or get assistance.
</ManagementCardContent>
<ManagementCardFooter>
<Button className="h-12 gap-x-2">
<AddIcon />
Open Support Centre
</Button>
</ManagementCardFooter>
</ManagementCard>
<ManagementCard>
<ManagementCardTitle>Delete Account</ManagementCardTitle>
<ManagementCardContent>
Once initiated, this action cannot be undone.
</ManagementCardContent>
<ManagementCardFooter>
<Button className="h-12 gap-x-2" variant="destructive">
<AddIcon />
Delete my Account
</Button>
</ManagementCardFooter>
</ManagementCard>
</div>
<DialogContent>
{openModal.changeAvatar && <ChangeAvatarForm />}
{openModal.changeEmail && (
<ChangeEmailForm currentValue={identity?.email || ""} />
)}
{openModal.changePassword && <ChangePasswordForm />}
{openModal.setupTwoFactor && <SetupTwoFactorDialog />}
</DialogContent>
</Dialog>
</GeneralLayout>
</Authenticated>
);
}
@ -199,15 +235,7 @@ const ChangeEmailSchema = z
return true;
});
const ChangeEmailForm = ({
open,
setOpen,
currentValue,
}: {
open: boolean;
setOpen: (value: boolean) => void;
currentValue: string;
}) => {
const ChangeEmailForm = ({ currentValue }: { currentValue: string }) => {
const { data: identity } = useGetIdentity<{ id: BaseKey }>();
const { mutate: updateEmail } = useUpdate();
const [form, fields] = useForm({
@ -234,38 +262,36 @@ const ChangeEmailForm = ({
});
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="p-8">
<DialogHeader>
<DialogTitle className="mb-8">Change Email</DialogTitle>
<div className="rounded-full px-4 py-2 w-fit text-sm bg-ring font-bold text-secondary-1">
{currentValue}
</div>
<form {...getFormProps(form)}>
<Field
className="mt-8"
inputProps={{ name: fields.email.name }}
labelProps={{ children: "New Email Address" }}
errors={fields.email.errors}
/>
<Field
inputProps={{ name: fields.password.name, type: "password" }}
labelProps={{ children: "Password" }}
errors={fields.password.errors}
/>
<Field
inputProps={{
name: fields.retypePassword.name,
type: "password",
}}
labelProps={{ children: "Retype Password" }}
errors={fields.retypePassword.errors}
/>
<Button className="w-full h-14">Change Email Address</Button>
</form>
</DialogHeader>
</DialogContent>
</Dialog>
<>
<DialogHeader>
<DialogTitle className="mb-8">Change Email</DialogTitle>
</DialogHeader>
<div className="rounded-full px-4 py-2 w-fit text-sm bg-ring font-bold text-secondary-1">
{currentValue}
</div>
<form {...getFormProps(form)}>
<Field
className="mt-8"
inputProps={{ name: fields.email.name }}
labelProps={{ children: "New Email Address" }}
errors={fields.email.errors}
/>
<Field
inputProps={{ name: fields.password.name, type: "password" }}
labelProps={{ children: "Password" }}
errors={fields.password.errors}
/>
<Field
inputProps={{
name: fields.retypePassword.name,
type: "password",
}}
labelProps={{ children: "Retype Password" }}
errors={fields.retypePassword.errors}
/>
<Button className="w-full h-14">Change Email Address</Button>
</form>
</>
);
};
@ -286,14 +312,8 @@ const ChangePasswordSchema = z
return true;
});
const ChangePasswordForm = ({
open,
setOpen,
}: {
open: boolean;
setOpen: (value: boolean) => void;
}) => {
const { mutate: updatePassword } = useUpdatePassword<UpdatePasswordFormRequest>();
const ChangePasswordForm = () => {
const { mutate: updatePassword } = useUpdatePassword<{ password: string }>();
const [form, fields] = useForm({
id: "login",
constraint: getZodConstraint(ChangePasswordSchema),
@ -307,98 +327,174 @@ const ChangePasswordForm = ({
const data = Object.fromEntries(new FormData(e.currentTarget).entries());
updatePassword({
currentPassword: data.currentPassword.toString(),
password: data.newPassword.toString(),
});
},
});
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="p-8">
<DialogHeader>
<DialogTitle className="mb-8">Change Password</DialogTitle>
<form {...getFormProps(form)}>
<Field
inputProps={{
name: fields.currentPassword.name,
type: "password",
}}
labelProps={{ children: "Current Password" }}
errors={fields.currentPassword.errors}
/>
<Field
inputProps={{ name: fields.newPassword.name, type: "password" }}
labelProps={{ children: "New Password" }}
errors={fields.newPassword.errors}
/>
<Field
inputProps={{
name: fields.retypePassword.name,
type: "password",
}}
labelProps={{ children: "Retype Password" }}
errors={fields.retypePassword.errors}
/>
<Button className="w-full h-14">Change Password</Button>
</form>
</DialogHeader>
</DialogContent>
</Dialog>
<>
<DialogHeader>
<DialogTitle className="mb-8">Change Password</DialogTitle>
</DialogHeader>
<form {...getFormProps(form)}>
<Field
inputProps={{
name: fields.currentPassword.name,
type: "password",
}}
labelProps={{ children: "Current Password" }}
errors={fields.currentPassword.errors}
/>
<Field
inputProps={{ name: fields.newPassword.name, type: "password" }}
labelProps={{ children: "New Password" }}
errors={fields.newPassword.errors}
/>
<Field
inputProps={{
name: fields.retypePassword.name,
type: "password",
}}
labelProps={{ children: "Retype Password" }}
errors={fields.retypePassword.errors}
/>
<Button className="w-full h-14">Change Password</Button>
</form>
</>
);
};
const SetupTwoFactorDialog = ({
open,
setOpen,
}: {
open: boolean;
setOpen: (value: boolean) => void;
}) => {
const SetupTwoFactorDialog = () => {
const [continueModal, setContinue] = useState<boolean>(false);
return (
<Dialog
open={open}
onOpenChange={(value) => {
setOpen(value);
setContinue(false);
}}>
<DialogContent className="p-8">
<DialogHeader>
<DialogTitle className="mb-8">Setup Two-Factor</DialogTitle>
<div className="flex flex-col gap-y-6">
{continueModal ? (
<>
<p className="text-sm text-primary-2">
Enter the authentication code generated in your two-factor
application to confirm your setup.
</p>
<Input fullWidth className="text-center" />
<Button className="w-full h-14">Confirm</Button>
</>
) : (
<>
<div className="p-6 flex justify-center border bg-secondary-2">
<img src={QRImg} alt="QR" className="h-36 w-36" />
</div>
<p className="font-semibold">
Dont have access to scan? Use this code instead.
</p>
<div className="p-4 border text-primary-2 text-center font-bold">
HHH7MFGAMPJ44OM44FGAMPJ44O232
</div>
<Button
className="w-full h-14"
onClick={() => setContinue(true)}>
Continue
</Button>
</>
)}
</div>
</DialogHeader>
</DialogContent>
</Dialog>
<>
<DialogHeader>
<DialogTitle className="mb-8">Setup Two-Factor</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-y-6">
{continueModal ? (
<>
<p className="text-sm text-primary-2">
Enter the authentication code generated in your two-factor
application to confirm your setup.
</p>
<Input fullWidth className="text-center" />
<Button className="w-full h-14">Confirm</Button>
</>
) : (
<>
<div className="p-6 flex justify-center border bg-secondary-2">
<img src={QRImg} alt="QR" className="h-36 w-36" />
</div>
<p className="font-semibold">
Dont have access to scan? Use this code instead.
</p>
<div className="p-4 border text-primary-2 text-center font-bold">
HHH7MFGAMPJ44OM44FGAMPJ44O232
</div>
<Button className="w-full h-14" onClick={() => setContinue(true)}>
Continue
</Button>
</>
)}
</div>
</>
);
};
const ChangeAvatarForm = () => {
const {
getRootProps,
getInputProps,
getFiles,
upload,
state,
removeFile,
cancelAll,
} = useUppy();
console.log({ state, files: getFiles() });
const isUploading = state === "uploading";
const isCompleted = state === "completed";
const hasStarted = state !== "idle" && state !== "initializing";
const file = getFiles()?.[0];
const imagePreview = useMemo(() => {
if (file) {
return URL.createObjectURL(file.data);
}
}, [file]);
return (
<>
<DialogHeader className="mb-6">
<DialogTitle>Edit Avatar</DialogTitle>
</DialogHeader>
{!hasStarted && !getFiles().length ? (
<div
{...getRootProps()}
className="border border-border rounded text-primary-2 bg-primary-dark h-48 flex flex-col items-center justify-center">
<input
hidden
aria-hidden
name="uppyFiles[]"
key={new Date().toISOString()}
multiple
{...getInputProps()}
/>
<CloudUploadIcon className="w-24 h-24 stroke stroke-primary-dark" />
<p>Drag & Drop Files or Browse</p>
</div>
) : null}
{!hasStarted && file && (
<div className="border border-border rounded p-4 bg-primary-dark relative">
<Button
className="absolute top-1/2 right-1/2 rounded-full bg-gray-800/50 hover:bg-primary p-2 text-sm"
onClick={() => removeFile(file?.id)}>
<Cross2Icon className="size-4" />
</Button>
<img
className="w-full h-48 object-contain"
src={imagePreview}
alt="New Avatar Preview"
/>
</div>
)}
{hasStarted ? (
<div className="flex flex-col items-center gap-y-2 w-full text-primary-1">
<CloudCheckIcon className="w-32 h-32" />
{isCompleted ? "Upload completed" : `0% completed`}
</div>
) : null}
{isUploading ? (
<DialogClose asChild onClick={cancelAll}>
<Button size={"lg"} className="mt-6">
Cancel
</Button>
</DialogClose>
) : null}
{isCompleted ? (
<DialogClose asChild>
<Button size={"lg"} className="mt-6">
Close
</Button>
</DialogClose>
) : null}
{!hasStarted && !isCompleted && !isUploading ? (
<Button size={"lg"} className="mt-6" onClick={upload}>
Upload
</Button>
) : null}
</>
);
};

View File

@ -3,6 +3,7 @@ import type { ColumnDef, RowData } from "@tanstack/react-table";
import { FileIcon, MoreIcon } from "~/components/icons";
import { Checkbox } from "~/components/ui/checkbox";
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "~/components/ui/dropdown-menu";
import { cn } from "~/utils";
// This type is used to define the shape of our data.
// You can use a Zod schema here if you want.
@ -66,12 +67,13 @@ export const columns: ColumnDef<File>[] = [
accessorKey: "createdOn",
size: 200,
header: "Created On",
cell: ({ row, table }) => (
cell: ({ row }) => (
<div className="flex items-center justify-between">
{row.getValue("createdOn")}
{(row.getIsSelected() || table.options.meta?.hoveredRowId === row.id) && (
<DropdownMenu>
<DropdownMenuTrigger>
<DropdownMenuTrigger className={
cn("hidden group-hover:block data-[state=open]:block", row.getIsSelected() && "block")
}>
<MoreIcon />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
@ -88,7 +90,6 @@ export const columns: ColumnDef<File>[] = [
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
)
}

View File

@ -9,7 +9,7 @@ import { Field, FieldCheckbox } from "~/components/forms"
import { getFormProps, useForm } from "@conform-to/react"
import { z } from "zod"
import { getZodConstraint, parseWithZod } from "@conform-to/zod"
import {useLogin, useRegister} from "@refinedev/core";
import {useLogin, useNotification, useRegister} from "@refinedev/core";
import {AuthFormRequest, RegisterFormRequest} from "~/data/auth-provider.js";
export const meta: MetaFunction = () => {
@ -43,14 +43,15 @@ const RegisterSchema = z
});
export default function Register() {
const register = useRegister<RegisterFormRequest>()
const login = useLogin<AuthFormRequest>();
const register = useRegister<RegisterFormRequest>()
const login = useLogin<AuthFormRequest>();
const { open } = useNotification();
const [form, fields] = useForm({
id: "register",
constraint: getZodConstraint(RegisterSchema),
onValidate({ formData }) {
return parseWithZod(formData, { schema: RegisterSchema });
},
id: "register",
constraint: getZodConstraint(RegisterSchema),
onValidate({formData}) {
return parseWithZod(formData, {schema: RegisterSchema});
},
onSubmit(e) {
e.preventDefault();
@ -60,17 +61,9 @@ export default function Register() {
password: data.password.toString(),
firstName: data.firstName.toString(),
lastName: data.lastName.toString(),
}, {
onSuccess: () => {
login.mutate({
email: data.email.toString(),
password: data.password.toString(),
rememberMe: false,
})
}
})
}
});
})
}
});
return (
<div className="p-10 h-screen relative">

View File

@ -9,6 +9,8 @@ import { Field } from "~/components/forms";
import { getFormProps, useForm } from "@conform-to/react";
import { z } from "zod";
import { getZodConstraint, parseWithZod } from "@conform-to/zod";
import { ToastAction } from "~/components/ui/toast";
import { useNotification } from "@refinedev/core";
export const meta: MetaFunction = () => {
return [{ title: "Sign Up" }];
@ -18,12 +20,26 @@ const RecoverPasswordSchema = z.object({
email: z.string().email(),
});
export default function RecoverPassword() {
const { open } = useNotification();
const [form, fields] = useForm({
id: "sign-up",
constraint: getZodConstraint(RecoverPasswordSchema),
onValidate({ formData }) {
return parseWithZod(formData, { schema: RecoverPasswordSchema });
},
onSubmit(e) {
e.preventDefault();
open?.({
type: "success",
message: "Password reset email sent",
description: "Check your email for a link to reset your password. If it doesnt appear within a few minutes, check your spam folder.",
action: <ToastAction altText="Cancel">Cancel</ToastAction>,
cancelMutation: () => {
console.log("cancel mutation");
},
})
}
});
// TODO: another detail is the reset password has no screen to either accept a new pass or

View File

@ -16,7 +16,7 @@
"@conform-to/react": "^1.0.2",
"@conform-to/zod": "^1.0.2",
"@fontsource-variable/manrope": "^5.0.19",
"@lumeweb/portal-sdk": "0.0.0-20240319140708",
"@lumeweb/portal-sdk": "0.0.0-20240320165911",
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-dialog": "^1.0.5",
@ -26,6 +26,7 @@
"@radix-ui/react-progress": "^1.0.3",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-toast": "^1.1.5",
"@refinedev/cli": "^2.16.1",
"@refinedev/core": "https://gitpkg.now.sh/LumeWeb/refine/packages/core?remix",
"@refinedev/devtools-internal": "https://gitpkg.now.sh/LumeWeb/refine/packages/devtools-internal?remix",