mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
Add active sessions page for dashboard account settings.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
fe392dd40f
commit
6df13f5356
@ -0,0 +1,289 @@
|
||||
'use client';
|
||||
|
||||
import { fromNow } from "@stackframe/stack-shared/dist/utils/dates";
|
||||
import { captureError } from "@stackframe/stack-shared/dist/utils/errors";
|
||||
import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { DotsThree, Monitor, DeviceMobile, Warning } from "@phosphor-icons/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useUser } from "@stackframe/stack";
|
||||
import { ActiveSession } from "../supporting/types";
|
||||
import { PageLayout } from "../page-layout";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function ActiveSessionsPage(props?: {
|
||||
mockSessions?: Array<{
|
||||
id: string,
|
||||
isCurrentSession: boolean,
|
||||
isImpersonation?: boolean,
|
||||
createdAt: string,
|
||||
lastUsedAt?: string,
|
||||
geoInfo?: {
|
||||
ip?: string,
|
||||
cityName?: string,
|
||||
},
|
||||
}>,
|
||||
mockMode?: boolean,
|
||||
}) {
|
||||
const userFromHook = useUser({ or: (props?.mockSessions || props?.mockMode) ? 'return-null' : 'throw' });
|
||||
const [isLoading, setIsLoading] = useState(!props?.mockSessions);
|
||||
const [isRevokingAll, setIsRevokingAll] = useState(false);
|
||||
const [sessions, setSessions] = useState<any[]>([]);
|
||||
const [showConfirmRevokeAll, setShowConfirmRevokeAll] = useState(false);
|
||||
|
||||
// Use mock data if provided
|
||||
const mockSessionsData = props?.mockSessions ? props.mockSessions.map(session => ({
|
||||
id: session.id,
|
||||
isCurrentSession: session.isCurrentSession,
|
||||
isImpersonation: session.isImpersonation || false,
|
||||
createdAt: session.createdAt,
|
||||
lastUsedAt: session.lastUsedAt,
|
||||
geoInfo: session.geoInfo,
|
||||
})) : [
|
||||
{
|
||||
id: 'current-session',
|
||||
isCurrentSession: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
lastUsedAt: new Date().toISOString(),
|
||||
geoInfo: { ip: '192.168.1.1', cityName: 'San Francisco' }
|
||||
},
|
||||
{
|
||||
id: 'mobile-session',
|
||||
isCurrentSession: false,
|
||||
createdAt: new Date(Date.now() - 86400000).toISOString(),
|
||||
lastUsedAt: new Date(Date.now() - 7200000).toISOString(),
|
||||
geoInfo: { ip: '10.0.0.1', cityName: 'New York' }
|
||||
}
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
if (props?.mockSessions) {
|
||||
setSessions(mockSessionsData as any);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (props?.mockMode && !userFromHook) {
|
||||
setSessions(mockSessionsData as any);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!userFromHook) return;
|
||||
|
||||
runAsynchronously(async () => {
|
||||
setIsLoading(true);
|
||||
const sessionsData = await userFromHook.getActiveSessions();
|
||||
setSessions(sessionsData);
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, [userFromHook, props?.mockSessions]);
|
||||
|
||||
const handleRevokeSession = async (sessionId: string) => {
|
||||
if (props?.mockSessions) {
|
||||
setSessions(prev => prev.filter(session => session.id !== sessionId));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!userFromHook) return;
|
||||
|
||||
try {
|
||||
await userFromHook.revokeSession(sessionId);
|
||||
setSessions(prev => prev.filter(session => session.id !== sessionId));
|
||||
} catch (error) {
|
||||
captureError("session-revoke", { sessionId, error });
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevokeAllSessions = async () => {
|
||||
setIsRevokingAll(true);
|
||||
try {
|
||||
if (props?.mockSessions) {
|
||||
setSessions(prevSessions => prevSessions.filter(session => session.isCurrentSession));
|
||||
} else if (userFromHook) {
|
||||
const deletionPromises = sessions
|
||||
.filter(session => !session.isCurrentSession)
|
||||
.map(session => userFromHook.revokeSession(session.id));
|
||||
await Promise.all(deletionPromises);
|
||||
setSessions(prevSessions => prevSessions.filter(session => session.isCurrentSession));
|
||||
}
|
||||
} catch (error) {
|
||||
captureError("all-sessions-revoke", { error, sessionIds: sessions.map(session => session.id) });
|
||||
throw error;
|
||||
} finally {
|
||||
setIsRevokingAll(false);
|
||||
setShowConfirmRevokeAll(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="border border-black/[0.08] dark:border-white/[0.08] bg-white/80 dark:bg-background/80 backdrop-blur-xl rounded-2xl p-6 shadow-sm ring-1 ring-black/[0.04] dark:ring-0 flex flex-col gap-6">
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
||||
<div>
|
||||
<h3 className="font-semibold text-base text-foreground leading-snug">
|
||||
Active Sessions
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-sm mt-1 leading-relaxed">
|
||||
These are devices where you're currently logged in. You can revoke access to end a session.
|
||||
</p>
|
||||
</div>
|
||||
{sessions.filter(s => !s.isCurrentSession).length > 0 && !isLoading && (
|
||||
showConfirmRevokeAll ? (
|
||||
<div className="flex gap-2 w-full md:w-auto">
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
loading={isRevokingAll}
|
||||
onClick={handleRevokeAllSessions}
|
||||
className="rounded-xl flex-1 md:flex-none px-4 py-2"
|
||||
>
|
||||
Confirm Revoke All
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isRevokingAll}
|
||||
onClick={() => setShowConfirmRevokeAll(false)}
|
||||
className="border-black/[0.08] dark:border-white/[0.08] rounded-xl flex-1 md:flex-none px-4 py-2"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowConfirmRevokeAll(true)}
|
||||
className="border-black/[0.08] dark:border-white/[0.08] rounded-xl hover:bg-zinc-50 dark:hover:bg-zinc-900 px-4 py-2 text-xs font-semibold w-full md:w-auto"
|
||||
>
|
||||
Revoke All Other Sessions
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-10 w-full rounded-xl" />
|
||||
<Skeleton className="h-[200px] w-full rounded-xl" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="border border-black/[0.06] dark:border-white/[0.06] rounded-xl overflow-hidden shadow-sm">
|
||||
<Table>
|
||||
<TableHeader className="bg-zinc-50/50 dark:bg-zinc-900/50">
|
||||
<TableRow className="border-b border-black/[0.06] dark:border-white/[0.06]">
|
||||
<TableHead className="py-3 px-4 font-semibold text-xs text-muted-foreground uppercase tracking-wider">Session</TableHead>
|
||||
<TableHead className="py-3 px-4 font-semibold text-xs text-muted-foreground uppercase tracking-wider">IP Address</TableHead>
|
||||
<TableHead className="py-3 px-4 font-semibold text-xs text-muted-foreground uppercase tracking-wider">Location</TableHead>
|
||||
<TableHead className="py-3 px-4 font-semibold text-xs text-muted-foreground uppercase tracking-wider">Last Used</TableHead>
|
||||
<TableHead className="py-3 px-4 text-right w-[60px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sessions.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground italic text-sm">
|
||||
No active sessions found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
sessions.map((session) => (
|
||||
<TableRow key={session.id} className="border-b border-black/[0.04] dark:border-white/[0.04] last:border-b-0 hover:bg-zinc-50/30 dark:hover:bg-zinc-900/30 transition-colors duration-150">
|
||||
<TableCell className="py-4 px-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-zinc-100 dark:bg-zinc-900 p-2 rounded-xl text-foreground shrink-0 border border-black/[0.04] dark:border-white/[0.04]">
|
||||
{session.id.includes("mobile") ? (
|
||||
<DeviceMobile className="h-5 w-5" />
|
||||
) : (
|
||||
<Monitor className="h-5 w-5" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-semibold text-foreground flex items-center gap-1.5">
|
||||
{session.isCurrentSession ? "Current Session" : "Other Session"}
|
||||
{session.isCurrentSession && (
|
||||
<Badge className="bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900 text-[10px] px-2 py-0 border-0 font-bold rounded-full">
|
||||
Active
|
||||
</Badge>
|
||||
)}
|
||||
</span>
|
||||
{session.isImpersonation && (
|
||||
<Badge className="bg-amber-50 text-amber-700 dark:bg-amber-950/40 dark:text-amber-400 border border-amber-200 dark:border-amber-900/30 text-[9px] px-1.5 py-0 font-semibold rounded-md w-fit mt-1">
|
||||
Impersonation
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground/80 mt-0.5">
|
||||
Signed in {new Date(session.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="py-4 px-4 text-sm text-foreground/90 font-medium">
|
||||
{session.geoInfo?.ip || '-'}
|
||||
</TableCell>
|
||||
<TableCell className="py-4 px-4 text-sm text-foreground/90 font-medium">
|
||||
{session.geoInfo?.cityName || 'Unknown'}
|
||||
</TableCell>
|
||||
<TableCell className="py-4 px-4">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
{session.lastUsedAt ? fromNow(new Date(session.lastUsedAt)) : "Never"}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground/80 mt-0.5" title={session.lastUsedAt ? new Date(session.lastUsedAt).toLocaleString() : ""}>
|
||||
{session.lastUsedAt ? new Date(session.lastUsedAt).toLocaleDateString() : ""}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="py-4 px-4 text-right">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0 rounded-lg text-muted-foreground hover:text-foreground hover:bg-zinc-100 dark:hover:bg-zinc-900 transition-colors"
|
||||
>
|
||||
<DotsThree className="h-5 w-5 weight-bold" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-[140px] rounded-xl border-black/[0.08] dark:border-white/[0.08] shadow-md">
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleRevokeSession(session.id)}
|
||||
disabled={session.isCurrentSession}
|
||||
className={cn(
|
||||
"cursor-pointer rounded-lg text-red-500 hover:text-red-600 focus:text-red-500",
|
||||
session.isCurrentSession ? "opacity-50 cursor-not-allowed" : ""
|
||||
)}
|
||||
>
|
||||
Revoke Session
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user