Add admin password reset flow
This commit is contained in:
@@ -5,7 +5,7 @@ import { AlertTriangle, CheckCircle, X } from 'lucide-react';
|
||||
interface ConfirmModalProps {
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
message: React.ReactNode;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
onConfirm: () => void;
|
||||
@@ -60,9 +60,9 @@ export const ConfirmModal: React.FC<ConfirmModalProps> = ({
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xl font-bold text-neutral-900 dark:text-neutral-100 tracking-tight">{title}</h3>
|
||||
<p className="text-sm font-medium text-neutral-500 dark:text-neutral-400 leading-relaxed">
|
||||
<div className="text-sm font-medium text-neutral-500 dark:text-neutral-400 leading-relaxed">
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 w-full mt-2">
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
|
||||
const DEVICE_ID_KEY = 'excalidash-device-id';
|
||||
|
||||
const getOrCreateDeviceId = (): string => {
|
||||
if (typeof window === 'undefined') return 'server';
|
||||
const existing = localStorage.getItem(DEVICE_ID_KEY);
|
||||
if (existing) return existing;
|
||||
|
||||
const generated =
|
||||
typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
|
||||
localStorage.setItem(DEVICE_ID_KEY, generated);
|
||||
return generated;
|
||||
};
|
||||
|
||||
const fnv1a = (input: string): number => {
|
||||
let hash = 0x811c9dc5;
|
||||
for (let i = 0; i < input.length; i += 1) {
|
||||
hash ^= input.charCodeAt(i);
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
return hash >>> 0;
|
||||
};
|
||||
|
||||
const toHsl = (n: number) => {
|
||||
const hue = n % 360;
|
||||
const sat = 60 + (n % 20);
|
||||
const light = 45 + (n % 10);
|
||||
return `hsl(${hue} ${sat}% ${light}%)`;
|
||||
};
|
||||
|
||||
const buildPattern = (seed: string) => {
|
||||
let x = fnv1a(seed);
|
||||
const nextBit = () => {
|
||||
// xorshift32
|
||||
x ^= x << 13;
|
||||
x ^= x >>> 17;
|
||||
x ^= x << 5;
|
||||
return (x >>> 0) & 1;
|
||||
};
|
||||
|
||||
const cells: boolean[][] = Array.from({ length: 5 }, () => Array.from({ length: 5 }, () => false));
|
||||
|
||||
// Generate left 3 columns, mirror to 5.
|
||||
for (let row = 0; row < 5; row += 1) {
|
||||
for (let col = 0; col < 3; col += 1) {
|
||||
const on = nextBit() === 1;
|
||||
cells[row][col] = on;
|
||||
cells[row][4 - col] = on;
|
||||
}
|
||||
}
|
||||
|
||||
const foreground = toHsl(x);
|
||||
const background = 'hsl(0 0% 98%)';
|
||||
const backgroundDark = 'hsl(0 0% 12%)';
|
||||
|
||||
return { cells, foreground, background, backgroundDark };
|
||||
};
|
||||
|
||||
export const FingerprintAvatar: React.FC<{
|
||||
size?: number;
|
||||
seed?: string;
|
||||
title?: string;
|
||||
className?: string;
|
||||
}> = ({ size = 32, seed, title = 'Device fingerprint', className }) => {
|
||||
const [deviceId] = useState(() => getOrCreateDeviceId());
|
||||
const effectiveSeed = seed || deviceId;
|
||||
|
||||
const { cells, foreground, background, backgroundDark } = useMemo(
|
||||
() => buildPattern(effectiveSeed),
|
||||
[effectiveSeed]
|
||||
);
|
||||
|
||||
const padding = 0.5;
|
||||
const viewBox = `${-padding} ${-padding} ${5 + padding * 2} ${5 + padding * 2}`;
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox={viewBox}
|
||||
role="img"
|
||||
aria-label={title}
|
||||
className={className}
|
||||
>
|
||||
<title>{title}</title>
|
||||
<rect
|
||||
x={-padding}
|
||||
y={-padding}
|
||||
width={5 + padding * 2}
|
||||
height={5 + padding * 2}
|
||||
rx={1.4}
|
||||
fill={background}
|
||||
className="dark:hidden"
|
||||
/>
|
||||
<rect
|
||||
x={-padding}
|
||||
y={-padding}
|
||||
width={5 + padding * 2}
|
||||
height={5 + padding * 2}
|
||||
rx={1.4}
|
||||
fill={backgroundDark}
|
||||
className="hidden dark:block"
|
||||
/>
|
||||
{cells.map((row, r) =>
|
||||
row.map((on, c) =>
|
||||
on ? <rect key={`${r}-${c}`} x={c} y={r} width={1} height={1} rx={0.2} fill={foreground} /> : null
|
||||
)
|
||||
)}
|
||||
<rect
|
||||
x={-padding}
|
||||
y={-padding}
|
||||
width={5 + padding * 2}
|
||||
height={5 + padding * 2}
|
||||
rx={1.4}
|
||||
fill="none"
|
||||
stroke="rgba(0,0,0,0.25)"
|
||||
className="dark:stroke-neutral-700"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,10 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { Menu, X } from 'lucide-react';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { UploadStatus } from './UploadStatus';
|
||||
import type { Collection } from '../types';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode;
|
||||
@@ -24,8 +27,11 @@ export const Layout: React.FC<LayoutProps> = ({
|
||||
onDeleteCollection,
|
||||
onDrop
|
||||
}) => {
|
||||
const location = useLocation();
|
||||
const [sidebarWidth, setSidebarWidth] = useState(260);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
|
||||
const sidebarRef = useRef<HTMLDivElement>(null);
|
||||
const startXRef = useRef(0);
|
||||
const startWidthRef = useRef(0);
|
||||
@@ -61,39 +67,115 @@ export const Layout: React.FC<LayoutProps> = ({
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia('(max-width: 1024px)');
|
||||
const sync = () => {
|
||||
setIsMobile(mq.matches);
|
||||
setIsSidebarOpen(!mq.matches);
|
||||
};
|
||||
|
||||
sync();
|
||||
mq.addEventListener('change', sync);
|
||||
return () => mq.removeEventListener('change', sync);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMobile) return;
|
||||
setIsSidebarOpen(false);
|
||||
}, [isMobile, location.pathname, location.search]);
|
||||
|
||||
return (
|
||||
<div className="h-screen w-full bg-[#F3F4F6] dark:bg-neutral-950 p-4 transition-colors duration-200 overflow-hidden">
|
||||
<div className="flex gap-4 items-start h-full">
|
||||
<aside
|
||||
ref={sidebarRef}
|
||||
className="flex-shrink-0 h-full bg-white dark:bg-neutral-900 rounded-2xl border-2 border-black dark:border-neutral-700 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] overflow-hidden z-20 transition-colors duration-200 relative"
|
||||
style={{ width: `${sidebarWidth}px` }}
|
||||
>
|
||||
<Sidebar
|
||||
collections={collections}
|
||||
selectedCollectionId={selectedCollectionId}
|
||||
onSelectCollection={onSelectCollection}
|
||||
onCreateCollection={onCreateCollection}
|
||||
onEditCollection={onEditCollection}
|
||||
onDeleteCollection={onDeleteCollection}
|
||||
onDrop={onDrop}
|
||||
/>
|
||||
|
||||
{/* Resize Handle */}
|
||||
<div className="h-screen w-full bg-[#F3F4F6] dark:bg-neutral-950 p-2 sm:p-4 transition-colors duration-200 overflow-hidden">
|
||||
{isMobile ? (
|
||||
<div className="relative h-full min-w-0">
|
||||
<main className="h-full min-w-0 bg-white/40 dark:bg-neutral-900/40 backdrop-blur-sm rounded-2xl border border-white/50 dark:border-neutral-800/50 shadow-sm transition-colors duration-200 overflow-hidden flex flex-col">
|
||||
<div className="px-3 pt-3 flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsSidebarOpen(v => !v)}
|
||||
className="inline-flex items-center justify-center h-11 w-11 rounded-xl border-2 border-black dark:border-neutral-700 bg-white/90 dark:bg-neutral-900/90 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] backdrop-blur-sm text-slate-900 dark:text-neutral-200 hover:-translate-y-0.5 transition-all"
|
||||
title={isSidebarOpen ? 'Close menu' : 'Open menu'}
|
||||
aria-label={isSidebarOpen ? 'Close menu' : 'Open menu'}
|
||||
>
|
||||
{isSidebarOpen ? <X size={20} /> : <Menu size={20} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0 overflow-y-auto">
|
||||
<div className="max-w-[1600px] w-full mx-auto p-4 sm:p-6 lg:p-8 min-h-full">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div
|
||||
className={`absolute top-0 right-0 w-1.5 h-full cursor-col-resize bg-transparent hover:bg-indigo-400 dark:hover:bg-indigo-500 transition-all duration-150 ${isResizing ? 'bg-indigo-500 dark:bg-indigo-400 w-2' : ''} group`}
|
||||
onMouseDown={handleMouseDown}
|
||||
title="Drag to resize sidebar"
|
||||
className={clsx(
|
||||
'fixed inset-0 z-30 bg-neutral-900/20 backdrop-blur-sm transition-opacity duration-150',
|
||||
isSidebarOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
||||
)}
|
||||
onClick={() => setIsSidebarOpen(false)}
|
||||
/>
|
||||
|
||||
<aside
|
||||
ref={sidebarRef}
|
||||
className={clsx(
|
||||
'fixed inset-y-4 left-2 sm:left-4 z-40 bg-white dark:bg-neutral-900 rounded-2xl border-2 border-black dark:border-neutral-700 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] overflow-hidden transition-transform duration-200',
|
||||
isSidebarOpen ? 'translate-x-0' : '-translate-x-[110%]'
|
||||
)}
|
||||
style={{ width: `${sidebarWidth}px` }}
|
||||
>
|
||||
<div className="absolute inset-y-0 -left-0.5 -right-0.5 bg-transparent hover:bg-indigo-500/10 dark:hover:bg-indigo-400/10 transition-colors duration-150" />
|
||||
</div>
|
||||
</aside>
|
||||
<main className="flex-1 bg-white/40 dark:bg-neutral-900/40 backdrop-blur-sm rounded-2xl border border-white/50 dark:border-neutral-800/50 shadow-sm h-full transition-colors duration-200 overflow-y-auto">
|
||||
<div className="max-w-[1600px] mx-auto p-6 lg:p-8 min-h-full">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<Sidebar
|
||||
collections={collections}
|
||||
selectedCollectionId={selectedCollectionId}
|
||||
onSelectCollection={onSelectCollection}
|
||||
onCreateCollection={onCreateCollection}
|
||||
onEditCollection={onEditCollection}
|
||||
onDeleteCollection={onDeleteCollection}
|
||||
onDrop={onDrop}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={`absolute top-0 right-0 w-1.5 h-full cursor-col-resize bg-transparent hover:bg-indigo-400 dark:hover:bg-indigo-500 transition-all duration-150 ${isResizing ? 'bg-indigo-500 dark:bg-indigo-400 w-2' : ''} group`}
|
||||
onMouseDown={handleMouseDown}
|
||||
title="Drag to resize sidebar"
|
||||
>
|
||||
<div className="absolute inset-y-0 -left-0.5 -right-0.5 bg-transparent hover:bg-indigo-500/10 dark:hover:bg-indigo-400/10 transition-colors duration-150" />
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-3 sm:gap-4 items-start h-full min-w-0">
|
||||
<aside
|
||||
ref={sidebarRef}
|
||||
className="flex-shrink-0 h-full bg-white dark:bg-neutral-900 rounded-2xl border-2 border-black dark:border-neutral-700 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] overflow-hidden z-20 transition-colors duration-200 relative"
|
||||
style={{ width: `${sidebarWidth}px` }}
|
||||
>
|
||||
<Sidebar
|
||||
collections={collections}
|
||||
selectedCollectionId={selectedCollectionId}
|
||||
onSelectCollection={onSelectCollection}
|
||||
onCreateCollection={onCreateCollection}
|
||||
onEditCollection={onEditCollection}
|
||||
onDeleteCollection={onDeleteCollection}
|
||||
onDrop={onDrop}
|
||||
/>
|
||||
|
||||
{/* Resize Handle */}
|
||||
<div
|
||||
className={`absolute top-0 right-0 w-1.5 h-full cursor-col-resize bg-transparent hover:bg-indigo-400 dark:hover:bg-indigo-500 transition-all duration-150 ${isResizing ? 'bg-indigo-500 dark:bg-indigo-400 w-2' : ''} group`}
|
||||
onMouseDown={handleMouseDown}
|
||||
title="Drag to resize sidebar"
|
||||
>
|
||||
<div className="absolute inset-y-0 -left-0.5 -right-0.5 bg-transparent hover:bg-indigo-500/10 dark:hover:bg-indigo-400/10 transition-colors duration-150" />
|
||||
</div>
|
||||
</aside>
|
||||
<main className="flex-1 min-w-0 bg-white/40 dark:bg-neutral-900/40 backdrop-blur-sm rounded-2xl border border-white/50 dark:border-neutral-800/50 shadow-sm h-full transition-colors duration-200 overflow-y-auto">
|
||||
<div className="max-w-[1600px] w-full mx-auto p-4 sm:p-6 lg:p-8 min-h-full">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)}
|
||||
<UploadStatus />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
@@ -7,7 +7,8 @@ interface ProtectedRouteProps {
|
||||
}
|
||||
|
||||
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
|
||||
const { isAuthenticated, loading, authEnabled, bootstrapRequired } = useAuth();
|
||||
const location = useLocation();
|
||||
const { isAuthenticated, loading, authEnabled, bootstrapRequired, user } = useAuth();
|
||||
|
||||
if (loading || authEnabled === null) {
|
||||
return (
|
||||
@@ -30,5 +31,10 @@ export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
// Force password reset before allowing app access.
|
||||
if (user?.mustResetPassword && location.pathname !== '/login') {
|
||||
return <Navigate to="/login?mustReset=1" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { LayoutGrid, Folder, Plus, Trash2, Edit2, Archive, FolderOpen, Settings as SettingsIcon, User, LogOut } from 'lucide-react';
|
||||
import { LayoutGrid, Folder, Plus, Trash2, Edit2, Archive, FolderOpen, Settings as SettingsIcon, User, LogOut, Shield } from 'lucide-react';
|
||||
import type { Collection } from '../types';
|
||||
import clsx from 'clsx';
|
||||
import { ConfirmModal } from './ConfirmModal';
|
||||
import { Logo } from './Logo';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { FingerprintAvatar } from './FingerprintAvatar';
|
||||
import { readImpersonationState, stopImpersonation as restoreImpersonation, type ImpersonationState } from '../utils/impersonation';
|
||||
|
||||
interface SidebarProps {
|
||||
collections: Collection[];
|
||||
@@ -123,6 +125,8 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const { logout, user, authEnabled } = useAuth();
|
||||
const isAdmin = user?.role === 'ADMIN';
|
||||
const [impersonation, setImpersonation] = useState<ImpersonationState | null>(null);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [newCollectionName, setNewCollectionName] = useState('');
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
@@ -137,6 +141,17 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
||||
return () => document.removeEventListener('click', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authEnabled) {
|
||||
setImpersonation(null);
|
||||
return;
|
||||
}
|
||||
const sync = () => setImpersonation(readImpersonationState());
|
||||
sync();
|
||||
window.addEventListener('storage', sync);
|
||||
return () => window.removeEventListener('storage', sync);
|
||||
}, [authEnabled]);
|
||||
|
||||
|
||||
const handleCreateSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -169,7 +184,7 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
||||
return (
|
||||
<>
|
||||
<div className="w-full flex flex-col h-full bg-transparent">
|
||||
<div className="p-5 pb-2">
|
||||
<div className="p-4 sm:p-5 pb-2">
|
||||
<h1 className="text-2xl text-slate-900 dark:text-white flex items-center gap-3 tracking-tight" style={{ fontFamily: 'Excalifont' }}>
|
||||
<Logo className="w-10 h-10" />
|
||||
<span className="mt-1">ExcaliDash</span>
|
||||
@@ -178,7 +193,7 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
||||
</div>
|
||||
|
||||
<nav
|
||||
className="flex-1 overflow-y-auto py-4 space-y-8 custom-scrollbar"
|
||||
className="flex-1 overflow-y-auto py-3 sm:py-4 space-y-4 sm:space-y-8 custom-scrollbar"
|
||||
onContextMenu={handleBackgroundContextMenu}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
@@ -260,7 +275,7 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="px-3 pt-4 pb-4 border-t border-slate-200/50 dark:border-slate-700/50 space-y-2">
|
||||
<div className="px-3 pt-3 sm:pt-4 pb-3 sm:pb-4 border-t border-slate-200/50 dark:border-slate-700/50 space-y-2">
|
||||
<button
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -301,6 +316,21 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{authEnabled && isAdmin && (
|
||||
<button
|
||||
onClick={() => navigate('/admin')}
|
||||
className={clsx(
|
||||
"w-full flex items-center gap-3 px-3 py-2 text-sm font-bold rounded-xl transition-all duration-200 border-2 border-black dark:border-neutral-700 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)]",
|
||||
selectedCollectionId === 'ADMIN'
|
||||
? "bg-indigo-50 dark:bg-neutral-800 text-indigo-900 dark:text-neutral-200 -translate-y-0.5"
|
||||
: "bg-white dark:bg-neutral-900 text-slate-900 dark:text-neutral-200 hover:bg-slate-50 dark:hover:bg-neutral-800 hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-0.5"
|
||||
)}
|
||||
>
|
||||
<Shield size={18} />
|
||||
<span className="min-w-0 flex-1 text-left">Admin</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => navigate('/settings')}
|
||||
className={clsx(
|
||||
@@ -317,10 +347,42 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
||||
{/* User info and logout */}
|
||||
{authEnabled && (
|
||||
<div className="mt-auto pt-4 border-t-2 border-slate-200 dark:border-neutral-700">
|
||||
{impersonation && (
|
||||
<div className="px-3 pb-2">
|
||||
<div className="p-3 bg-amber-50 dark:bg-amber-900/20 border-2 border-amber-200 dark:border-amber-800 rounded-xl flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] font-bold text-amber-900 dark:text-amber-200 uppercase tracking-wide">
|
||||
Impersonating
|
||||
</div>
|
||||
<div className="text-xs font-semibold text-amber-900 dark:text-amber-200 truncate">
|
||||
{user?.email}
|
||||
</div>
|
||||
<div className="text-[11px] text-amber-800/80 dark:text-amber-200/70 truncate">
|
||||
Return to {impersonation.impersonator.email}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!restoreImpersonation()) return;
|
||||
window.location.reload();
|
||||
}}
|
||||
className="px-2.5 py-1.5 text-[11px] font-bold rounded-lg border-2 border-amber-300 dark:border-amber-700 bg-white dark:bg-neutral-900 text-amber-800 dark:text-amber-200 hover:bg-amber-100 dark:hover:bg-amber-900/30 transition-all flex-shrink-0"
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{user && (
|
||||
<div className="px-3 py-2 text-xs text-slate-500 dark:text-neutral-500 mb-2">
|
||||
<div className="font-semibold text-slate-700 dark:text-neutral-300">{user.name}</div>
|
||||
<div className="truncate">{user.email}</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<FingerprintAvatar size={28} className="flex-shrink-0 sm:hidden" title="Browser profile" />
|
||||
<FingerprintAvatar size={32} className="flex-shrink-0 hidden sm:block" title="Browser profile" />
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold text-slate-700 dark:text-neutral-300 truncate leading-tight">{user.name}</div>
|
||||
<div className="truncate leading-tight">{user.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user