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}