Skip to content

Commit 34fc922

Browse files
feat: admin session revocation, security activity improvements, and CSS fixes
- Add session revocation: revokeOtherSessions server action with client-side UA parsing, auto-revoke on password reset/change, manual 'Other Devices' button - Security Activity table: remove pagination, flex-based scrollable layout with sticky header, all columns always visible, sessions_revoked shows 'Signed Out Other Devices' with dash placeholders for device info - Revoke confirmation dialog: simplified copy and UX - Dashboard: add OCR workspace link on desktop banner, mobile quick action swap - Mobile overflow: relax AdminShell root/flex/main overflow chain for scroll, overflow-x-hidden on html/body, max-width 100vw on mobile body - Global scrollbar hiding: scrollbar-width none, webkit scrollbar display none - Fix .scrollbar-none CSS selector: was applying width:0/height:0/display:none to elements themselves instead of only their scrollbar pseudo-elements, causing OCR, Team, and About pages to render invisible - Migration script: add session revocation function, update README with database migration docs
1 parent 4e316f5 commit 34fc922

9 files changed

Lines changed: 588 additions & 127 deletions

File tree

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ lipy/
5252
├── scripts/
5353
│ ├── common/ # Shared utilities
5454
│ ├── dataset/ # Dataset management
55+
│ ├── database/ # Database schema and migrations
56+
│ │ └── migration.sql # Full Supabase schema (tables, RLS, policies, storage)
5557
│ ├── model/ # Model management
5658
│ └── requirements.txt
5759
@@ -171,6 +173,7 @@ Each major component contains its own documentation.
171173
| `backend/README.md` | Backend API (endpoints, response models, status-based predictions), inference pipeline, Docker deployment, and Azure setup |
172174
| `frontend/README.md` | Frontend setup and development |
173175
| `scripts/README.md` | Dataset and model management utilities |
176+
| `scripts/database/migration.sql` | Supabase database schema — run in SQL Editor to set up all tables (`lipy_contributors`, `lipy_sessions`, `lipy_samples`, `security_events`), RLS policies, indexes, session revocation function, and storage bucket. Safe to re-run (uses `IF NOT EXISTS`) |
174177
| `notebooks/L.ipynb` | Complete training workflow from dataset download to model export |
175178

176179
### Key Features

frontend/app/admin/dataset/page.tsx

Lines changed: 30 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -186,24 +186,34 @@ export default function DatasetViewerPage() {
186186

187187
if (err1) throw err1;
188188

189-
// 2. Get distinct contributors count by querying distinct contributor_ids
189+
// 2. Get contributors who have at least 1 sample (with count)
190190
const { data: contribData, error: err2 } = await client
191191
.from('lipy_samples')
192-
.select('contributor_id, contributor_name');
192+
.select('contributor_id, contributor_name')
193+
.not('storage_path', 'is', null);
193194

194195
if (err2) throw err2;
195196

196197
let uniqueContributors = 0;
197-
const uniqueContribsMap = new Map();
198+
const uniqueContribsMap = new Map<string, { name: string; count: number }>();
198199

199200
if (contribData) {
200201
contribData.forEach((d: any) => {
201202
if (d.contributor_id) {
202-
uniqueContribsMap.set(d.contributor_id, d.contributor_name || d.contributor_id);
203+
const existing = uniqueContribsMap.get(d.contributor_id);
204+
if (existing) {
205+
existing.count++;
206+
} else {
207+
uniqueContribsMap.set(d.contributor_id, { name: d.contributor_name || d.contributor_id, count: 1 });
208+
}
203209
}
204210
});
205-
uniqueContributors = uniqueContribsMap.size;
206-
const list = Array.from(uniqueContribsMap.entries()).map(([id, name]) => ({ id, name }));
211+
// Only keep contributors with at least 1 image
212+
const list = Array.from(uniqueContribsMap.entries())
213+
.filter(([, v]) => v.count > 0)
214+
.map(([id, v]) => ({ id, name: v.name }))
215+
.sort((a, b) => a.name.localeCompare(b.name));
216+
uniqueContributors = list.length;
207217
setContributors(list);
208218
}
209219

@@ -543,10 +553,13 @@ export default function DatasetViewerPage() {
543553

544554
// 2. Attempt to delete from Storage (soft fail if error)
545555
try {
546-
const bucketName = sample.storage_bucket || 'lipi-samples';
547-
await supabase.storage
548-
.from(bucketName)
549-
.remove([sample.storage_path]);
556+
const bucketName = sample.storage_bucket || 'lipy-samples';
557+
const cleanPath = cleanStoragePath(sample.storage_path);
558+
if (cleanPath) {
559+
await supabase.storage
560+
.from(bucketName)
561+
.remove([cleanPath]);
562+
}
550563
} catch (err) {
551564
console.warn('Failed to delete storage file, but DB record was deleted:', err);
552565
}
@@ -608,11 +621,14 @@ export default function DatasetViewerPage() {
608621
const bucketToPaths = new Map<string, string[]>();
609622
selectedSamples.forEach(s => {
610623
if (s.storage_path) {
611-
const bucketName = s.storage_bucket || 'lipi-samples';
612-
if (!bucketToPaths.has(bucketName)) {
613-
bucketToPaths.set(bucketName, []);
624+
const bucketName = s.storage_bucket || 'lipy-samples';
625+
const cleanPath = cleanStoragePath(s.storage_path);
626+
if (cleanPath) {
627+
if (!bucketToPaths.has(bucketName)) {
628+
bucketToPaths.set(bucketName, []);
629+
}
630+
bucketToPaths.get(bucketName)!.push(cleanPath);
614631
}
615-
bucketToPaths.get(bucketName)!.push(s.storage_path);
616632
}
617633
});
618634

frontend/app/admin/page.tsx

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
TrendingUp, Shield, Settings, Loader2,
1010
Mail, CalendarDays, Fingerprint, ChevronRight,
1111
Database, UserCheck, Users, AlertCircle, Github,
12-
CheckCircle2, Hash
12+
CheckCircle2, Hash, ScanLine
1313
} from 'lucide-react';
1414
import Link from 'next/link';
1515
import { AdminShell } from '@/components/admin/AdminShell';
@@ -293,7 +293,7 @@ export default function AdminDashboardPage() {
293293

294294
return (
295295
<AdminShell title="Dashboard" subtitle="Administrator overview">
296-
<div ref={containerRef} className="p-3 sm:p-4 md:p-5 space-y-4 w-full max-w-none flex flex-col flex-1 h-full overflow-hidden">
296+
<div ref={containerRef} className="p-3 sm:p-4 md:p-5 space-y-4 w-full max-w-none flex flex-col flex-1 sm:h-full overflow-x-hidden overflow-y-visible sm:overflow-hidden">
297297

298298
{/* Welcome Banner */}
299299
<motion.div
@@ -311,14 +311,22 @@ export default function AdminDashboardPage() {
311311
{(userName || userEmail || 'A').charAt(0).toUpperCase()}
312312
</div>
313313
)}
314-
<h2 className="min-w-0 text-sm sm:text-base leading-tight tracking-normal">
314+
<h2 className="min-w-0 text-sm sm:text-base leading-tight tracking-normal flex-1">
315315
<span className="block text-[10px] sm:text-[11px] uppercase tracking-[0.24em] opacity-70 font-semibold font-display">
316316
{greeting}
317317
</span>
318318
<span className="block truncate text-base sm:text-[1.35rem] font-display font-bold tracking-[-0.02em]">
319319
{userName ? userName.trim().split(' ')[0] : (userEmail ? userEmail.split('@')[0].split(/[\._-]/)[0].replace(/\b\w/g, c => c.toUpperCase()) : 'Admin')}
320320
</span>
321321
</h2>
322+
<Link
323+
href="/"
324+
className="hidden sm:flex items-center gap-1.5 px-3 py-2 rounded-xl border border-stone-800 bg-[#0F0F0F] hover:bg-stone-900/60 hover:border-stone-700 text-stone-400 hover:text-stone-200 text-xs font-semibold transition-all shrink-0"
325+
>
326+
<ScanLine className="w-3.5 h-3.5" />
327+
OCR
328+
<ArrowUpRight className="w-3 h-3 opacity-50" />
329+
</Link>
322330
</div>
323331
</motion.div>
324332

@@ -437,15 +445,27 @@ export default function AdminDashboardPage() {
437445
color="bg-blue-950/30 text-blue-500"
438446
delay={0.1}
439447
/>
440-
<QuickAction
441-
title="Lipy GitHub"
442-
description="Open the source repository for the project"
443-
icon={Github}
444-
href="https://github.com/biranchikulesika/lipy"
445-
color="bg-emerald-950/30 text-emerald-500"
446-
delay={0.15}
447-
external
448-
/>
448+
<div className="sm:hidden">
449+
<QuickAction
450+
title="OCR Workspace"
451+
description="Recognize handwritten Odia characters"
452+
icon={ScanLine}
453+
href="/"
454+
color="bg-violet-950/30 text-violet-500"
455+
delay={0.15}
456+
/>
457+
</div>
458+
<div className="hidden sm:block">
459+
<QuickAction
460+
title="Lipy GitHub"
461+
description="Open the source repository for the project"
462+
icon={Github}
463+
href="https://github.com/biranchikulesika/lipy"
464+
color="bg-emerald-950/30 text-emerald-500"
465+
delay={0.15}
466+
external
467+
/>
468+
</div>
449469
</div>
450470
</div>
451471
</div>

frontend/app/admin/reset-password/page.tsx

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { createClient } from '@/lib/supabase/client';
77
import { Loader2, KeyRound, Eye, EyeOff, ShieldAlert, Check, ArrowLeft, LogIn, CircleCheckBig } from 'lucide-react';
88
import { Logo } from '@/components/ui/logo';
99
import Link from 'next/link';
10+
import { revokeOtherSessions } from '@/app/admin/security-actions';
1011

1112
type Phase = 'loading' | 'expired' | 'form' | 'success';
1213

@@ -94,6 +95,21 @@ function ResetPasswordContent() {
9495
setError(msg || 'Failed to update password.');
9596
}
9697
} else {
98+
// Auto-revoke all old sessions after password reset
99+
try {
100+
const supabase = createClient();
101+
const { data: { session } } = await supabase.auth.getSession();
102+
if (session?.access_token) {
103+
const base64 = session.access_token.split('.')[1];
104+
const padded = base64.replace(/-/g, '+').replace(/_/g, '/');
105+
const payload = JSON.parse(atob(padded));
106+
const userId = payload.sub as string | undefined;
107+
const sessionId = payload.session_id as string | undefined;
108+
if (userId && sessionId) {
109+
await revokeOtherSessions(userId, sessionId);
110+
}
111+
}
112+
} catch { /* non-critical */ }
97113
setPhase('success');
98114
}
99115
} catch (err: unknown) {
@@ -176,7 +192,7 @@ function ResetPasswordContent() {
176192
<div className="space-y-2">
177193
<h1 className="text-xl font-bold tracking-tight">Password Updated</h1>
178194
<p className="text-sm text-stone-400 leading-relaxed">
179-
Your password has been changed successfully. Sign in with your new password to continue.
195+
Your password has been changed successfully. All other sessions have been logged out for security. Sign in with your new password to continue.
180196
</p>
181197
</div>
182198
<Link

frontend/app/admin/security-actions.ts

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ export type SecurityEventType =
1515
| 'passkey_register'
1616
| 'provider_link'
1717
| 'provider_unlink'
18-
| 'active_session';
18+
| 'active_session'
19+
| 'sessions_revoked';
1920

2021
function parseUserAgent(ua: string | null) {
2122
if (!ua) return { browser: 'Unknown', os: 'Unknown' };
@@ -174,3 +175,72 @@ export async function logSecurityEventDirect(
174175
// Gracefully ignore
175176
}
176177
}
178+
179+
export async function revokeOtherSessions(
180+
userId: string,
181+
currentSessionId: string,
182+
clientInfo?: { browser: string; os: string; ip?: string }
183+
): Promise<{ success: boolean; revokedCount: number; error?: string }> {
184+
try {
185+
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
186+
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
187+
if (!supabaseUrl || !supabaseServiceKey) {
188+
return { success: false, revokedCount: 0, error: 'Missing Supabase configuration' };
189+
}
190+
191+
const { createClient } = await import('@supabase/supabase-js');
192+
const supabase = createClient(supabaseUrl, supabaseServiceKey);
193+
194+
const { data, error } = await supabase.rpc('revoke_other_sessions', {
195+
p_user_id: userId,
196+
p_current_session_id: currentSessionId,
197+
});
198+
199+
if (error) {
200+
console.error('revokeOtherSessions RPC error:', error);
201+
return { success: false, revokedCount: 0, error: error.message };
202+
}
203+
204+
const revokedCount = typeof data === 'number' ? data : 0;
205+
206+
// Log the event using client-provided browser/OS (more reliable than server-side UA parsing)
207+
try {
208+
let browser = 'Unknown';
209+
let os = 'Unknown';
210+
211+
if (clientInfo?.browser) {
212+
// Client sends raw navigator.userAgent — parse it
213+
const parsed = parseUserAgent(clientInfo.browser);
214+
browser = parsed.browser;
215+
os = parsed.os;
216+
} else if (clientInfo?.os) {
217+
// Client sent pre-parsed platform string
218+
os = clientInfo.os;
219+
}
220+
221+
const ip = clientInfo?.ip || 'unknown';
222+
223+
await supabase.from('security_events').insert({
224+
user_id: userId,
225+
event_type: 'sessions_revoked',
226+
status: 'Success',
227+
device_info: getDeviceLabel(browser, os),
228+
browser,
229+
os,
230+
ip_address: ip,
231+
metadata: { revoked_count: revokedCount, current_session_id: currentSessionId },
232+
});
233+
} catch {
234+
// Non-critical: don't fail the revoke if logging fails
235+
}
236+
237+
return { success: true, revokedCount };
238+
} catch (err: unknown) {
239+
console.error('revokeOtherSessions error:', err);
240+
return {
241+
success: false,
242+
revokedCount: 0,
243+
error: err instanceof Error ? err.message : 'Unknown error',
244+
};
245+
}
246+
}

frontend/app/globals.css

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,12 @@ html {
3535
-moz-user-select: none;
3636
-ms-user-select: none;
3737
user-select: none;
38+
scrollbar-width: none;
39+
-ms-overflow-style: none;
40+
}
41+
42+
html::-webkit-scrollbar {
43+
display: none;
3844
}
3945

4046
input, textarea, [contenteditable="true"] {
@@ -46,27 +52,46 @@ input, textarea, [contenteditable="true"] {
4652

4753
body {
4854
height: 100dvh;
49-
overflow: hidden;
55+
overflow-y: auto;
5056
color: var(--foreground);
5157
background:
5258
radial-gradient(circle at top, rgba(111, 195, 182, 0.1), transparent 26%),
5359
radial-gradient(circle at bottom right, rgba(60, 144, 131, 0.1), transparent 24%),
5460
linear-gradient(180deg, var(--background), var(--background-deep));
5561
font-family: var(--font-body), sans-serif;
62+
scrollbar-width: none;
63+
-ms-overflow-style: none;
5664
}
5765

58-
.scrollbar-none {
59-
scrollbar-width: none !important;
60-
-ms-overflow-style: none !important;
66+
body::-webkit-scrollbar {
67+
display: none;
6168
}
6269

63-
.scrollbar-none::-webkit-scrollbar {
70+
@media (max-width: 639px) {
71+
html {
72+
overflow: hidden;
73+
}
74+
body {
75+
overflow-x: hidden;
76+
overflow-y: auto;
77+
max-width: 100vw;
78+
width: 100%;
79+
}
80+
}
81+
82+
.scrollbar-none::-webkit-scrollbar,
83+
*::-webkit-scrollbar {
6484
display: none !important;
6585
width: 0 !important;
6686
height: 0 !important;
6787
background: transparent !important;
6888
}
6989

90+
* {
91+
scrollbar-width: none !important;
92+
-ms-overflow-style: none !important;
93+
}
94+
7095
::selection {
7196
background: rgba(60, 144, 131, 0.22);
7297
}

frontend/components/admin/AdminShell.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,7 @@ export function AdminShell({
200200

201201
return (
202202
<SidebarContext.Provider value={{ collapsed }}>
203-
<div className="h-screen bg-[#070707] text-[#F5F5F5] font-sans selection:bg-blue-900 flex flex-col overflow-hidden">
203+
<div className="min-h-dvh sm:h-dvh bg-[#070707] text-[#F5F5F5] font-sans selection:bg-blue-900 flex flex-col overflow-x-hidden overflow-y-auto sm:overflow-hidden">
204204

205205
{/* Mobile Top Bar */}
206206
<header className="sticky top-0 z-50 h-14 border-b border-stone-900 bg-[#0A0A0A]/95 backdrop-blur-xl md:hidden">
@@ -236,7 +236,7 @@ export function AdminShell({
236236
</header>
237237

238238
{/* ─── Split layout below Header ─── */}
239-
<div className="flex flex-1 min-h-0 overflow-hidden">
239+
<div className="flex flex-1 sm:min-h-0 overflow-x-hidden overflow-y-visible sm:overflow-hidden">
240240

241241
{/* ─── Desktop Sidebar ─── */}
242242
<aside
@@ -342,7 +342,7 @@ export function AdminShell({
342342
</AnimatePresence>
343343

344344
{/* Page Content */}
345-
<main className="flex-1 overflow-hidden flex flex-col">
345+
<main className="flex-1 overflow-x-hidden overflow-y-visible sm:overflow-hidden flex flex-col">
346346
{children}
347347
</main>
348348
</div>

0 commit comments

Comments
 (0)