diff --git a/package.json b/package.json index 62140c3..ab609c2 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "input-otp": "^1.2.4", "lossless-json": "^4.0.2", "lucide-react": "^0.441.0", + "luxon": "^3.5.0", "next": "14.2.13", "next-client-cookies": "^1.1.1", "next-themes": "^0.3.0", @@ -50,6 +51,7 @@ "zustand": "^5.0.0-rc.2" }, "devDependencies": { + "@types/luxon": "^3.4.2", "@types/node": "^22.0.0", "@types/react": "^18", "@types/react-dom": "^18", diff --git a/src/app/(pages)/dashboard/user/settings/page.tsx b/src/app/(pages)/dashboard/user/settings/page.tsx index c71435b..bef5fb2 100644 --- a/src/app/(pages)/dashboard/user/settings/page.tsx +++ b/src/app/(pages)/dashboard/user/settings/page.tsx @@ -4,6 +4,7 @@ import { ReactElement } from "react"; import { Separator } from "@/components/ui/separator"; import TFASetting from "@/components/dashboard/user/settings/tfa/tfa-setting"; import UserSettingsHeader from "@/components/dashboard/user/user-settings-header"; +import DevicesSetting from "@/components/dashboard/user/settings/device/devices-setting"; /** * The user settings page. @@ -18,6 +19,8 @@ const UserSettingsPage = (): ReactElement => (
+ +
); diff --git a/src/app/types/user/device.ts b/src/app/types/user/device.ts new file mode 100644 index 0000000..15157ce --- /dev/null +++ b/src/app/types/user/device.ts @@ -0,0 +1,46 @@ +/** + * A device logged into a + * {@link User}'s account. + */ +export type Device = { + /** + * The type of this device. + */ + type: "DESKTOP" | "TABLET" | "PHONE" | "UNKNOWN"; + + /** + * The browser type of this device. + */ + browserType: + | "FIREFOX" + | "EDGE" + | "CHROME" + | "SAFARI" + | "SAMSUNGBROWSER" + | "UNKNOWN"; + + /** + * The IP address of this device. + */ + ip: string; + + /** + * The location of this device, if known. + */ + location: string | undefined; + + /** + * The user agent of this device. + */ + userAgent: string; + + /** + * The session snowflake associated with this device. + */ + sessionSnowflake: string; + + /** + * The date this device first logged into the account. + */ + firstLogin: Date; +}; diff --git a/src/app/types/user/session.ts b/src/app/types/user/session.ts index 958b82d..702fbdf 100644 --- a/src/app/types/user/session.ts +++ b/src/app/types/user/session.ts @@ -2,6 +2,11 @@ * A session of a {@link User}. */ export type Session = { + /** + * The snowflake of this session. + */ + snowflake: string; + /** * The access token for this session. */ diff --git a/src/components/dashboard/user/settings/device/device.tsx b/src/components/dashboard/user/settings/device/device.tsx new file mode 100644 index 0000000..0354f7c --- /dev/null +++ b/src/components/dashboard/user/settings/device/device.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { ReactElement, useEffect, useState } from "react"; +import { Device as DeviceType } from "@/app/types/user/device"; +import { capitalizeWords } from "@/lib/string"; +import { DateTime } from "luxon"; +import { + ArrowLeftEndOnRectangleIcon, + ComputerDesktopIcon, +} from "@heroicons/react/24/outline"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; + +const deviceIcons = { + DESKTOP: , + TABLET: , + PHONE: , + UNKNOWN: , +}; + +const Device = ({ + device, + current, +}: { + device: DeviceType; + current: boolean; +}): ReactElement => { + const [timeSinceFirstLogin, setTimeSinceFirstLogin] = useState( + DateTime.fromISO(device.firstLogin.toString()).toRelative() + ); + + useEffect(() => { + const interval = setInterval(() => { + setTimeSinceFirstLogin( + DateTime.fromISO(device.firstLogin.toString()).toRelative() + ); + }, 60000); + return () => clearInterval(interval); + }, [device.firstLogin]); + + return ( +
+ {/* Device & Browser Icons */} +
+
+ {deviceIcons[device.type]} +
+
+ + {/* Name & Location */} +
+

+ {capitalizeWords(device.type)} ·{" "} + {capitalizeWords(device.browserType)} +

+

+ {device.location ?? "Unknown Location"} ·{" "} + {timeSinceFirstLogin} +

+
+ + {/* Corner Content */} +
+ {/* Current Badge */} + {current && ( + + This Device + + )} + + {/* Controls */} + {!current && ( +
+ +
+ )} +
+
+ ); +}; + +export default Device; diff --git a/src/components/dashboard/user/settings/device/devices-setting.tsx b/src/components/dashboard/user/settings/device/devices-setting.tsx new file mode 100644 index 0000000..9b9cb7f --- /dev/null +++ b/src/components/dashboard/user/settings/device/devices-setting.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { ReactElement, useCallback, useEffect, useState } from "react"; +import { User } from "@/app/types/user/user"; +import { useUserContext } from "@/app/provider/user-provider"; +import { UserState } from "@/app/store/user-store"; +import { Session } from "@/app/types/user/session"; +import { Device as DeviceType } from "@/app/types/user/device"; +import { apiRequest } from "@/lib/api"; +import Device from "@/components/dashboard/user/settings/device/device"; +import { DateTime } from "luxon"; + +/** + * The setting that allows a + * {@link User} to view the + * devices that they are using. + * + * @return the setting jsx + */ +const DevicesSetting = (): ReactElement => { + const session: Session | undefined = useUserContext( + (state: UserState) => state.session + ); + + const [devices, setDevices] = useState(); + + /** + * Fetch the user's devices. + */ + const fetchDevices = useCallback(async () => { + const { data } = await apiRequest({ + endpoint: "/user/devices", + session, + }); + setDevices(data); + }, [session]); + + useEffect(() => { + fetchDevices(); + }, [fetchDevices]); + + return ( +
+ {/* Name & Description */} +
+

Devices

+

+ Here is a list of devices logged into your Pulse App + account. +

+
+ + {/* Setting */} +
+ {devices + ?.sort( + (a: DeviceType, b: DeviceType) => + DateTime.fromISO( + b.firstLogin.toString() + ).toMillis() - + DateTime.fromISO(a.firstLogin.toString()).toMillis() + ) + .map((device: DeviceType, index: number) => ( + + ))} +
+
+ ); +}; + +export default DevicesSetting; diff --git a/src/components/dashboard/user/settings/tfa/tfa-setting.tsx b/src/components/dashboard/user/settings/tfa/tfa-setting.tsx index 604d3c3..1599050 100644 --- a/src/components/dashboard/user/settings/tfa/tfa-setting.tsx +++ b/src/components/dashboard/user/settings/tfa/tfa-setting.tsx @@ -195,7 +195,7 @@ const TFASetting = (): ReactElement => { )} {/* Notice */} - {!enabledTfa && ( + {tfaResponse && !enabledTfa && ( NOTE:Enabling two-factor auth will log you out of all other devices. diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx new file mode 100644 index 0000000..48873e6 --- /dev/null +++ b/src/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const badgeVariants = cva( + "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +); + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ); +} + +export { Badge, badgeVariants }; diff --git a/src/components/user/user-tfa-prompt.tsx b/src/components/user/user-tfa-prompt.tsx index 04a4668..066318a 100644 --- a/src/components/user/user-tfa-prompt.tsx +++ b/src/components/user/user-tfa-prompt.tsx @@ -132,7 +132,7 @@ const UserTfaPrompt = ({ {children} {/* Header */} - + Two-Factor Border {message}