feat: user session management (#2056)

* user session management

* WIP

* cleanup

* license

* cleanup

* don't cache index

* rename current device property

* fix
This commit is contained in:
Philip Okugbe
2026-03-26 20:00:04 +00:00
committed by GitHub
parent 4e8f533b91
commit 803f1f0b81
32 changed files with 928 additions and 49 deletions
@@ -708,5 +708,20 @@
"Resend verification email": "Resend verification email",
"Verification email sent. Please check your inbox.": "Verification email sent. Please check your inbox.",
"Failed to resend verification email. Please try again.": "Failed to resend verification email. Please try again.",
"We've sent you an email with your associated workspaces.": "We've sent you an email with your associated workspaces."
"We've sent you an email with your associated workspaces.": "We've sent you an email with your associated workspaces.",
"Load more": "Load more",
"Log out of all devices": "Log out of all devices",
"Log out of all sessions except this device": "Log out of all sessions except this device",
"This Device": "This Device",
"Unknown device": "Unknown device",
"No active sessions": "No active sessions",
"Session revoked": "Session revoked",
"All other sessions revoked": "All other sessions revoked",
"Last used": "Last used",
"Created": "Created",
"Rename": "Rename",
"Publish": "Publish",
"Security": "Security",
"Enforce SSO": "Enforce SSO",
"Once enforced, members will not be able to login with email and password.": "Once enforced, members will not be able to login with email and password."
}
@@ -1,6 +1,6 @@
import { z } from "zod/v4";
import React from "react";
import { Button, Group, Modal, Textarea } from "@mantine/core";
import React, { useRef } from "react";
import { Button, Divider, Group, Modal, Stack, Textarea } from "@mantine/core";
import { useForm } from "@mantine/form";
import { zod4Resolver } from "mantine-form-zod-resolver";
import { useTranslation } from "react-i18next";
@@ -49,6 +49,7 @@ interface ActivateLicenseFormProps {
export function ActivateLicenseForm({ onClose }: ActivateLicenseFormProps) {
const { t } = useTranslation();
const activateLicenseMutation = useActivateMutation();
const fileInputRef = useRef<HTMLInputElement>(null);
const form = useForm<FormValues>({
validate: zod4Resolver(formSchema),
@@ -63,29 +64,68 @@ export function ActivateLicenseForm({ onClose }: ActivateLicenseFormProps) {
onClose?.();
}
function handleFileUpload(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
const content = (e.target?.result as string)?.trim();
if (content) {
form.setFieldValue("licenseKey", content);
handleSubmit({ licenseKey: content });
}
};
reader.readAsText(file);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Textarea
label={t("License key")}
description="Enter a valid enterprise license key. Contact sales@docmost.com to purchase one."
placeholder={t("e.g eyJhb.....")}
variant="filled"
autosize
minRows={3}
maxRows={5}
data-autofocus
{...form.getInputProps("licenseKey")}
<input
type="file"
accept=".txt"
ref={fileInputRef}
onChange={handleFileUpload}
hidden
/>
<Group justify="flex-end" mt="md">
<Button
type="submit"
disabled={activateLicenseMutation.isPending}
loading={activateLicenseMutation.isPending}
>
{t("Save")}
</Button>
</Group>
<Stack gap="xs">
<Textarea
label={t("License key")}
placeholder={t("e.g eyJhb.....")}
variant="filled"
autosize
minRows={3}
maxRows={5}
data-autofocus
{...form.getInputProps("licenseKey")}
/>
<Group justify="flex-end">
<Button
type="submit"
disabled={activateLicenseMutation.isPending}
loading={activateLicenseMutation.isPending}
>
{t("Save")}
</Button>
</Group>
<Divider label={t("Or")} labelPosition="center" />
<Group justify="center">
<Button
variant="light"
onClick={() => fileInputRef.current?.click()}
>
{t("Upload license file")}
</Button>
</Group>
</Stack>
</form>
);
}
@@ -68,7 +68,11 @@ export default function OssDetails() {
</List>
<Text size="sm" c="dimmed">
Contact <a href="mailto:sales@docmost.com?subject=Enterprise%20License%20Inquiry">sales@docmost.com </a> to purchase an enterprise license.
Get an enterprise trial key at <a href="https://customers.docmost.com/" target="_blank" rel="noopener noreferrer">customers.docmost.com</a>.
</Text>
<Text size="sm" c="dimmed">
Visit <a href="https://docmost.com/pricing" target="_blank" rel="noopener noreferrer">docmost.com/pricing</a> to purchase an enterprise license.
</Text>
</Stack>
</Stack>
@@ -0,0 +1,165 @@
import { useState } from "react";
import {
Button,
Divider,
Group,
Skeleton,
Stack,
Table,
Text,
} from "@mantine/core";
import { IconDevices } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import {
useGetSessionsQuery,
useRevokeSessionMutation,
useRevokeAllSessionsMutation,
} from "@/features/session/queries/session-query";
import { formattedDate } from "@/lib/time";
const PAGE_SIZE = 5;
export default function SessionList() {
const { t } = useTranslation();
const { data: sessions, isLoading } = useGetSessionsQuery();
const revokeSessionMutation = useRevokeSessionMutation();
const revokeAllSessionsMutation = useRevokeAllSessionsMutation();
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
const otherSessions = sessions?.filter((s) => !s?.isCurrentDevice) ?? [];
const visibleSessions = sessions?.slice(0, visibleCount) ?? [];
const hasMore = sessions && visibleCount < sessions.length;
if (isLoading) {
return (
<Table verticalSpacing="md">
<Table.Thead>
<Table.Tr>
<Table.Th>{t("Device Name")}</Table.Th>
<Table.Th>{t("Last Active")}</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{[1, 2, 3].map((i) => (
<Table.Tr key={i}>
<Table.Td>
<Group gap="xs">
<Skeleton height={18} width={18} radius="sm" />
<Skeleton height={14} width={140} radius="xs" />
</Group>
</Table.Td>
<Table.Td>
<Skeleton height={14} width={120} radius="xs" />
</Table.Td>
<Table.Td>
<Skeleton height={30} width={70} radius="sm" />
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
);
}
return (
<Stack>
{otherSessions.length > 0 && (
<>
<div>
<Text fw={500}>{t("Log out of all devices")}</Text>
<Group justify="space-between" align="center" mt={4}>
<Text size="sm" c="dimmed">
{t(
"Log out of all sessions except this device",
)}
</Text>
<Button
variant="outline"
color="red"
size="xs"
loading={revokeAllSessionsMutation.isPending}
onClick={() => revokeAllSessionsMutation.mutate()}
>
{t("Log out of all devices")}
</Button>
</Group>
</div>
<Divider />
</>
)}
<Table verticalSpacing="md">
<Table.Thead>
<Table.Tr>
<Table.Th>{t("Device Name")}</Table.Th>
<Table.Th>{t("Last Active")}</Table.Th>
{otherSessions.length > 0 && <Table.Th />}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{visibleSessions.map((session) => (
<Table.Tr key={session.id}>
<Table.Td>
<Group gap="xs">
<IconDevices size={18} stroke={1.5} />
<div>
<Text size="sm">
{session.deviceName || t("Unknown device")}
</Text>
{session?.isCurrentDevice && (
<Text size="xs" c="blue">
{t("This Device")}
</Text>
)}
</div>
</Group>
</Table.Td>
<Table.Td>
<Text size="sm">
{session?.isCurrentDevice
? t("Now")
: formattedDate(new Date(session.lastActiveAt))}
</Text>
</Table.Td>
{otherSessions.length > 0 && (
<Table.Td>
{!session?.isCurrentDevice && (
<Button
variant="outline"
size="xs"
loading={revokeSessionMutation.isPending}
onClick={() =>
revokeSessionMutation.mutate({
sessionId: session.id,
})
}
>
{t("Log out")}
</Button>
)}
</Table.Td>
)}
</Table.Tr>
))}
</Table.Tbody>
</Table>
{hasMore && (
<Button
variant="subtle"
size="xs"
onClick={() => setVisibleCount((c) => c + PAGE_SIZE)}
>
{t("Load more")}
</Button>
)}
{(!sessions || sessions.length === 0) && (
<Text size="sm" c="dimmed" ta="center">
{t("No active sessions")}
</Text>
)}
</Stack>
);
}
@@ -0,0 +1,55 @@
import {
useMutation,
useQuery,
useQueryClient,
UseQueryResult,
} from "@tanstack/react-query";
import {
getSessions,
revokeSession,
revokeAllSessions,
} from "@/features/session/services/session-service";
import { ISession } from "@/features/session/types/session.types";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
export function useGetSessionsQuery(): UseQueryResult<ISession[], Error> {
return useQuery({
queryKey: ["session-list"],
queryFn: () => getSessions(),
});
}
export function useRevokeSessionMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<void, Error, { sessionId: string }>({
mutationFn: (data) => revokeSession(data),
onSuccess: () => {
notifications.show({ message: t("Session revoked") });
queryClient.invalidateQueries({ queryKey: ["session-list"] });
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}
export function useRevokeAllSessionsMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<void, Error, void>({
mutationFn: () => revokeAllSessions(),
onSuccess: () => {
notifications.show({ message: t("All other sessions revoked") });
queryClient.invalidateQueries({ queryKey: ["session-list"] });
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}
@@ -0,0 +1,17 @@
import api from "@/lib/api-client";
import { ISession } from "@/features/session/types/session.types";
export async function getSessions(): Promise<ISession[]> {
const req = await api.post<{ sessions: ISession[] }>("/sessions");
return req.data.sessions;
}
export async function revokeSession(data: {
sessionId: string;
}): Promise<void> {
await api.post("/sessions/revoke", data);
}
export async function revokeAllSessions(): Promise<void> {
await api.post("/sessions/revoke-all");
}
@@ -0,0 +1,8 @@
export type ISession = {
id: string;
deviceName: string | null;
geoLocation: string | null;
lastActiveAt: string;
createdAt: string;
isCurrentDevice?: boolean;
};
@@ -1,9 +1,8 @@
import { useAtom } from "jotai";
import { focusAtom } from "jotai-optics";
import { z } from "zod/v4";
import { useForm } from "@mantine/form";
import { zod4Resolver } from "mantine-form-zod-resolver";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
import { userAtom } from "@/features/user/atoms/current-user-atom.ts";
import { updateUser } from "@/features/user/services/user-service.ts";
import { IUser } from "@/features/user/types/user.types.ts";
import { useState } from "react";
@@ -17,18 +16,15 @@ const formSchema = z.object({
type FormValues = z.infer<typeof formSchema>;
const userAtom = focusAtom(currentUserAtom, (optic) => optic.prop("user"));
export default function AccountNameForm() {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
const [currentUser] = useAtom(currentUserAtom);
const [, setUser] = useAtom(userAtom);
const [user, setUser] = useAtom(userAtom);
const form = useForm<FormValues>({
validate: zod4Resolver(formSchema),
initialValues: {
name: currentUser?.user.name,
name: user?.name,
},
});
@@ -8,6 +8,7 @@ import { getAppName } from "@/lib/config.ts";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { AccountMfaSection } from "@/features/user/components/account-mfa-section";
import SessionList from "@/features/session/components/session-list";
export default function AccountSettings() {
const { t } = useTranslation();
@@ -36,6 +37,10 @@ export default function AccountSettings() {
<Divider my="lg" />
<AccountMfaSection />
<Divider my="lg" />
<SessionList />
</>
);
}