Skip to content

Commit

Permalink
Merge pull request sinamics#512 from sinamics/user-devices
Browse files Browse the repository at this point in the history
Add active devices list with logout functionality
  • Loading branch information
sinamics authored Aug 26, 2024
2 parents dc32316 + 40a6759 commit a0dc54e
Show file tree
Hide file tree
Showing 35 changed files with 875 additions and 233 deletions.
32 changes: 32 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
"socket.io": "^4.7.5",
"socket.io-client": "^4.7.5",
"superjson": "1.9.1",
"ua-parser-js": "^1.0.38",
"unique-names-generator": "^4.7.1",
"unzipper": "^0.10.14",
"usehooks-ts": "^2.9.1",
Expand All @@ -85,6 +86,7 @@
"@types/react": "^18.0.28",
"@types/react-dom": "^18.0.11",
"@types/testing-library__jest-dom": "^5.14.5",
"@types/ua-parser-js": "^0.7.39",
"@types/unzipper": "^0.10.6",
"autoprefixer": "^10.4.7",
"dotenv": "^16.0.3",
Expand Down
30 changes: 30 additions & 0 deletions prisma/migrations/20240826093637_user_device/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
-- CreateTable
CREATE TABLE "UserDevice" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"deviceType" TEXT NOT NULL,
"ipAddress" TEXT,
"location" TEXT,
"deviceId" TEXT NOT NULL,
"browser" TEXT NOT NULL,
"browserVersion" TEXT NOT NULL,
"os" TEXT NOT NULL,
"osVersion" TEXT NOT NULL,
"lastActive" TIMESTAMP(3) NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "UserDevice_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE UNIQUE INDEX "UserDevice_deviceId_key" ON "UserDevice"("deviceId");

-- CreateIndex
CREATE INDEX "UserDevice_userId_idx" ON "UserDevice"("userId");

-- CreateIndex
CREATE INDEX "UserDevice_deviceId_idx" ON "UserDevice"("deviceId");

-- AddForeignKey
ALTER TABLE "UserDevice" ADD CONSTRAINT "UserDevice_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
21 changes: 21 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,27 @@ model User {
apiTokens APIToken[]
webhooks Webhook[]
invitations Invitation[] @relation("InvitationsSent")
UserDevice UserDevice[]
}

model UserDevice {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
deviceType String
ipAddress String?
location String?
deviceId String @unique
browser String
browserVersion String
os String
osVersion String
lastActive DateTime
isActive Boolean @default(true)
createdAt DateTime @default(now())
@@index([userId])
@@index([deviceId])
}

model VerificationToken {
Expand Down
2 changes: 2 additions & 0 deletions src/__tests__/components/loginForm.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ describe("CredentialsForm", () => {
redirect: false,
email: "[email protected]",
password: "testpassword",
userAgent:
"Mozilla/5.0 (linux) AppleWebKit/537.36 (KHTML, like Gecko) jsdom/20.0.3",
totpCode: "",
});
});
Expand Down
1 change: 1 addition & 0 deletions src/components/auth/credentialsForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ const CredentialsForm: React.FC = () => {
const response = await signIn("credentials", {
redirect: false,
totpCode,
userAgent: navigator.userAgent,
...formData,
});

Expand Down
5 changes: 3 additions & 2 deletions src/components/auth/oauthLogin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,10 @@ const OAuthLogin: React.FC = () => {

const oAuthHandler = async (providerId: string) => {
setLoading(true);

try {
const result = await signIn(providerId, { redirect: false });
const result = await signIn(providerId, {
redirect: false,
});

if (result?.error) {
toast.error(`Error occurred: ${result.error}`, { duration: 10000 });
Expand Down
1 change: 1 addition & 0 deletions src/components/auth/registerForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ const RegisterForm: React.FC = () => {
void (async () => {
const result = await signIn("credentials", {
redirect: false,
userAgent: navigator.userAgent,
...formData,
});
setLoading(false);
Expand Down
1 change: 1 addition & 0 deletions src/components/auth/registerOrganizationInvite.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ const RegisterOrganizationInviteForm: React.FC<Iprops> = ({
void (async () => {
const result = await signIn("credentials", {
redirect: false,
userAgent: navigator.userAgent,
...formData,
});
setLoading(false);
Expand Down
116 changes: 116 additions & 0 deletions src/components/auth/userDevices.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import type { UserDevice } from "@prisma/client";
import cn from "classnames";
import Smartphone from "~/icons/smartphone";
import Monitor from "~/icons/monitor";
import Tablet from "~/icons/tablet";
import { api } from "~/utils/api";
import { useTranslations } from "next-intl";
import { signOut } from "next-auth/react";
import { generateDeviceId, parseUA } from "~/utils/devices";

const formatLastActive = (date) => {
return new Date(date).toLocaleString("no-NO");
};

const DeviceInfo = ({ device, isCurrentDevice }) => {
const t = useTranslations();
return (
<div>
<p
className={cn("font-semibold", {
"text-green-700": isCurrentDevice,
})}
>
{`${device.os} ${device.browser}`}
</p>
<p className="text-sm text-gray-500">
{formatLastActive(device.lastActive)}
{device.ipAddress && ` - ${device.ipAddress}`}
</p>
{isCurrentDevice && (
<p className="text-xs text-green-600 font-semibold">
{t("userSettings.account.userDevices.activeNow")}
</p>
)}
</div>
);
};

const DeviceIcon = ({ deviceType }: { deviceType: string }) => {
switch (deviceType.toLowerCase()) {
case "mobile":
return <Smartphone className="w-6 h-6 mr-2" />;
case "tablet":
return <Tablet className="w-6 h-6 mr-2" />;
default:
return <Monitor className="w-6 h-6 mr-2" />;
}
};

const ListUserDevices: React.FC<{ devices: UserDevice[] }> = ({ devices }) => {
const t = useTranslations();
const { data: me, refetch } = api.auth.me.useQuery();

const { mutate: deleteUserDevice, isLoading: deleteLoading } =
api.auth.deleteUserDevice.useMutation({
onSuccess: () => {
// Refresh the devices list
refetch();
},
});

const isCurrentDevice = (device: UserDevice) => {
return (
device?.deviceId === generateDeviceId(parseUA(navigator.userAgent), me.id)?.deviceId
);
};

// sort devices, current device first
devices?.sort((a, b) => {
if (isCurrentDevice(a)) return -1;
if (isCurrentDevice(b)) return 1;
return 0;
});
return (
<div className="mx-auto">
<div className="flex justify-between">
<h1 className="text-md font-semibold mb-4">
{t("userSettings.account.userDevices.connectedDevices")}
</h1>
{/* <button className="btn btn-sm btn-error btn-outline">Logout All</button> */}
</div>
<div className="space-y-2 max-h-[500px] overflow-auto custom-scrollbar">
{devices && devices.length > 0 ? (
devices.map((device) => (
<div
key={device.id}
className={cn(
"flex items-center justify-between px-4 py-1 rounded-lg shadow border",
{ "border-l-4 border-green-500": isCurrentDevice(device) },
)}
>
<div className="flex items-center">
<DeviceIcon deviceType={device.deviceType} />
<DeviceInfo device={device} isCurrentDevice={isCurrentDevice(device)} />
</div>
<button
disabled={deleteLoading}
className="btn btn-sm btn-primary"
onClick={() => {
deleteUserDevice({ deviceId: device.deviceId });
isCurrentDevice(device) && signOut();
}}
>
{t("userSettings.account.userDevices.logout")}
</button>
</div>
))
) : (
<p>{t("userSettings.account.userDevices.noDevicesFound")}</p>
)}
</div>
</div>
);
};

export default ListUserDevices;
29 changes: 29 additions & 0 deletions src/icons/monitor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
interface Icon {
// add optional className prop
className?: string;
onClick?: () => void;
}

const Monitor = ({ className, onClick, ...rest }: Icon) => {
return (
<span onClick={onClick}>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="currentColor"
className={`h-4 w-4 cursor-pointer text-primary ${className}`}
{...rest}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 17.25v1.007a3 3 0 0 1-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0 1 15 18.257V17.25m6-12V15a2.25 2.25 0 0 1-2.25 2.25H5.25A2.25 2.25 0 0 1 3 15V5.25m18 0A2.25 2.25 0 0 0 18.75 3H5.25A2.25 2.25 0 0 0 3 5.25m18 0V12a2.25 2.25 0 0 1-2.25 2.25H5.25A2.25 2.25 0 0 1 3 12V5.25"
/>
</svg>
</span>
);
};

export default Monitor;
29 changes: 29 additions & 0 deletions src/icons/smartphone.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
interface Icon {
// add optional className prop
className?: string;
onClick?: () => void;
}

const SmartPhone = ({ className, onClick, ...rest }: Icon) => {
return (
<span onClick={onClick}>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="currentColor"
className={`h-4 w-4 cursor-pointer text-primary ${className}`}
{...rest}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M10.5 1.5H8.25A2.25 2.25 0 0 0 6 3.75v16.5a2.25 2.25 0 0 0 2.25 2.25h7.5A2.25 2.25 0 0 0 18 20.25V3.75a2.25 2.25 0 0 0-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3"
/>
</svg>
</span>
);
};

export default SmartPhone;
29 changes: 29 additions & 0 deletions src/icons/tablet.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
interface Icon {
// add optional className prop
className?: string;
onClick?: () => void;
}

const Tablet = ({ className, onClick, ...rest }: Icon) => {
return (
<span onClick={onClick}>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="currentColor"
className={`h-4 w-4 cursor-pointer text-primary ${className}`}
{...rest}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M10.5 19.5h3m-6.75 2.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-15a2.25 2.25 0 0 0-2.25-2.25H6.75A2.25 2.25 0 0 0 4.5 4.5v15a2.25 2.25 0 0 0 2.25 2.25Z"
/>
</svg>
</span>
);
};

export default Tablet;
Loading

0 comments on commit a0dc54e

Please sign in to comment.