devices in user settings
All checks were successful
Deploy / deploy (ubuntu-latest, 2.44.0) (push) Successful in 2m17s
All checks were successful
Deploy / deploy (ubuntu-latest, 2.44.0) (push) Successful in 2m17s
This commit is contained in:
parent
df1f65470d
commit
365ffb0e87
@ -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>
|
||||||
);
|
);
|
||||||
|
46
src/app/types/user/device.ts
Normal file
46
src/app/types/user/device.ts
Normal file
@ -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.
|
||||||
*/
|
*/
|
||||||
|
88
src/components/dashboard/user/settings/device/device.tsx
Normal file
88
src/components/dashboard/user/settings/device/device.tsx
Normal file
@ -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.
|
||||||
|
36
src/components/ui/badge.tsx
Normal file
36
src/components/ui/badge.tsx
Normal file
@ -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>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user