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",
|
||||
"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",
|
||||
|
@ -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 => (
|
||||
<div className="flex flex-col gap-5">
|
||||
<Separator className="opacity-65" />
|
||||
<TFASetting />
|
||||
<Separator className="opacity-65" />
|
||||
<DevicesSetting />
|
||||
</div>
|
||||
</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}.
|
||||
*/
|
||||
export type Session = {
|
||||
/**
|
||||
* The snowflake of this session.
|
||||
*/
|
||||
snowflake: string;
|
||||
|
||||
/**
|
||||
* 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 */}
|
||||
{!enabledTfa && (
|
||||
{tfaResponse && !enabledTfa && (
|
||||
<DialogFooter className="sm:justify-center gap-1.5 text-sm opacity-75 select-none">
|
||||
<b>NOTE:</b>Enabling two-factor auth will log
|
||||
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>
|
||||
<DialogContent>
|
||||
{/* Header */}
|
||||
<DialogHeader>
|
||||
<DialogHeader className="select-none pointer-events-none">
|
||||
<DialogTitle>Two-Factor Border</DialogTitle>
|
||||
<DialogDescription>{message}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
Loading…
x
Reference in New Issue
Block a user