devices in user settings
All checks were successful
Deploy / deploy (ubuntu-latest, 2.44.0) (push) Successful in 2m17s

This commit is contained in:
Braydon 2024-09-20 02:41:53 -04:00
parent df1f65470d
commit 365ffb0e87
9 changed files with 259 additions and 2 deletions

@ -33,6 +33,7 @@
"input-otp": "^1.2.4", "input-otp": "^1.2.4",
"lossless-json": "^4.0.2", "lossless-json": "^4.0.2",
"lucide-react": "^0.441.0", "lucide-react": "^0.441.0",
"luxon": "^3.5.0",
"next": "14.2.13", "next": "14.2.13",
"next-client-cookies": "^1.1.1", "next-client-cookies": "^1.1.1",
"next-themes": "^0.3.0", "next-themes": "^0.3.0",
@ -50,6 +51,7 @@
"zustand": "^5.0.0-rc.2" "zustand": "^5.0.0-rc.2"
}, },
"devDependencies": { "devDependencies": {
"@types/luxon": "^3.4.2",
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"@types/react": "^18", "@types/react": "^18",
"@types/react-dom": "^18", "@types/react-dom": "^18",

@ -4,6 +4,7 @@ import { ReactElement } from "react";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import TFASetting from "@/components/dashboard/user/settings/tfa/tfa-setting"; import TFASetting from "@/components/dashboard/user/settings/tfa/tfa-setting";
import UserSettingsHeader from "@/components/dashboard/user/user-settings-header"; import UserSettingsHeader from "@/components/dashboard/user/user-settings-header";
import DevicesSetting from "@/components/dashboard/user/settings/device/devices-setting";
/** /**
* The user settings page. * The user settings page.
@ -18,6 +19,8 @@ const UserSettingsPage = (): ReactElement => (
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
<Separator className="opacity-65" /> <Separator className="opacity-65" />
<TFASetting /> <TFASetting />
<Separator className="opacity-65" />
<DevicesSetting />
</div> </div>
</main> </main>
); );

@ -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;
};

@ -2,6 +2,11 @@
* A session of a {@link User}. * A session of a {@link User}.
*/ */
export type Session = { export type Session = {
/**
* The snowflake of this session.
*/
snowflake: string;
/** /**
* The access token for this session. * The access token for this session.
*/ */

@ -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: <ComputerDesktopIcon />,
TABLET: <ComputerDesktopIcon />,
PHONE: <ComputerDesktopIcon />,
UNKNOWN: <ComputerDesktopIcon />,
};
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 (
<div className="relative p-3 flex gap-3 items-center border bg-background/30 rounded-lg hover:opacity-90 transition-all transform-gpu select-none">
{/* Device & Browser Icons */}
<div className="p-2.5 relative flex justify-center items-center bg-zinc-800/75 rounded-full">
<div className="relative w-6 h-6">
{deviceIcons[device.type]}
</div>
</div>
{/* Name & Location */}
<div className="flex flex-col gap-0.5 text-sm">
<h1 className="font-semibold">
{capitalizeWords(device.type)} ·{" "}
{capitalizeWords(device.browserType)}
</h1>
<p className="opacity-75">
{device.location ?? "Unknown Location"} ·{" "}
{timeSinceFirstLogin}
</p>
</div>
{/* Corner Content */}
<div className="absolute top-1 right-1 flex">
{/* Current Badge */}
{current && (
<Badge className="bg-zinc-900" variant="outline">
This Device
</Badge>
)}
{/* Controls */}
{!current && (
<div className="flex gap-2">
<Button
className="p-0 w-5 h-5 text-red-500 hover:bg-transparent hover:text-red-500/75"
size="icon"
variant="ghost"
>
<ArrowLeftEndOnRectangleIcon />
</Button>
</div>
)}
</div>
</div>
);
};
export default Device;

@ -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<DeviceType[] | undefined>();
/**
* Fetch the user's devices.
*/
const fetchDevices = useCallback(async () => {
const { data } = await apiRequest<DeviceType[]>({
endpoint: "/user/devices",
session,
});
setDevices(data);
}, [session]);
useEffect(() => {
fetchDevices();
}, [fetchDevices]);
return (
<div className="px-5 flex flex-col gap-3.5 justify-center">
{/* Name & Description */}
<div className="w-96 flex flex-col gap-0.5 select-none pointer-events-none">
<h1 className="text-lg font-bold">Devices</h1>
<p className="w-[25rem] text-sm opacity-75">
Here is a list of devices logged into your Pulse App
account.
</p>
</div>
{/* Setting */}
<div className="w-[27.7rem] flex flex-col gap-2">
{devices
?.sort(
(a: DeviceType, b: DeviceType) =>
DateTime.fromISO(
b.firstLogin.toString()
).toMillis() -
DateTime.fromISO(a.firstLogin.toString()).toMillis()
)
.map((device: DeviceType, index: number) => (
<Device
key={index}
device={device}
current={
session?.snowflake === device.sessionSnowflake
}
/>
))}
</div>
</div>
);
};
export default DevicesSetting;

@ -195,7 +195,7 @@ const TFASetting = (): ReactElement => {
)} )}
{/* Notice */} {/* Notice */}
{!enabledTfa && ( {tfaResponse && !enabledTfa && (
<DialogFooter className="sm:justify-center gap-1.5 text-sm opacity-75 select-none"> <DialogFooter className="sm:justify-center gap-1.5 text-sm opacity-75 select-none">
<b>NOTE:</b>Enabling two-factor auth will log <b>NOTE:</b>Enabling two-factor auth will log
you out of all other devices. you out of all other devices.

@ -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<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants };

@ -132,7 +132,7 @@ const UserTfaPrompt = ({
<DialogTrigger>{children}</DialogTrigger> <DialogTrigger>{children}</DialogTrigger>
<DialogContent> <DialogContent>
{/* Header */} {/* Header */}
<DialogHeader> <DialogHeader className="select-none pointer-events-none">
<DialogTitle>Two-Factor Border</DialogTitle> <DialogTitle>Two-Factor Border</DialogTitle>
<DialogDescription>{message}</DialogDescription> <DialogDescription>{message}</DialogDescription>
</DialogHeader> </DialogHeader>