nickel
This commit is contained in:
parent
3fea8a85c8
commit
f0fbefa642
62
backend/drive/google_shortcuts.py
Normal file
62
backend/drive/google_shortcuts.py
Normal file
@ -0,0 +1,62 @@
|
||||
"""Safe previews for Google Drive Desktop shortcut files (.gdoc/.gsheet/.gslides).
|
||||
|
||||
Google-native documents are not stored inside these shortcuts. The preview
|
||||
opens the linked document on Google with its normal Google permissions.
|
||||
"""
|
||||
from html import escape
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
SHORTCUT_TYPES = {
|
||||
'.gdoc': ('document', 'Google Docs'),
|
||||
'.gsheet': ('spreadsheets', 'Google Sheets'),
|
||||
'.gslides': ('presentation', 'Google Slides'),
|
||||
}
|
||||
MAX_SHORTCUT_SIZE = 64 * 1024
|
||||
GOOGLE_ID = re.compile(r'^[A-Za-z0-9_-]{10,}$')
|
||||
|
||||
|
||||
def google_shortcut_url(path):
|
||||
"""Read a tiny Google shortcut and reject untrusted/off-domain redirects."""
|
||||
path = Path(path)
|
||||
subtype, _ = SHORTCUT_TYPES.get(path.suffix.lower(), (None, None))
|
||||
if subtype is None or path.stat().st_size > MAX_SHORTCUT_SIZE:
|
||||
raise ValueError('Fichier Google incompatible.')
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding='utf-8-sig'))
|
||||
url = data['url']
|
||||
except (OSError, ValueError, KeyError, TypeError, UnicodeError) as exc:
|
||||
raise ValueError('Raccourci Google illisible.') from exc
|
||||
if not isinstance(url, str) or len(url) > 4096:
|
||||
raise ValueError('URL Google invalide.')
|
||||
parsed = urlsplit(url)
|
||||
if parsed.scheme != 'https' or parsed.username or parsed.password or parsed.port or parsed.fragment:
|
||||
raise ValueError('Lien Google non autorisé.')
|
||||
if parsed.hostname == 'docs.google.com':
|
||||
prefix = f'/{subtype}/d/'
|
||||
if not parsed.path.startswith(prefix):
|
||||
raise ValueError('Lien Google incompatible avec le document.')
|
||||
doc_id = parsed.path[len(prefix):].split('/')[0]
|
||||
if not GOOGLE_ID.fullmatch(doc_id):
|
||||
raise ValueError('ID Google incorrect.')
|
||||
elif parsed.hostname == 'drive.google.com' and parsed.path == '/open':
|
||||
doc_id = parse_qs(parsed.query).get('id', [''])[0]
|
||||
if not GOOGLE_ID.fullmatch(doc_id):
|
||||
raise ValueError('ID Google incorrect.')
|
||||
else:
|
||||
raise ValueError('Lien Google non autorisé.')
|
||||
return url
|
||||
|
||||
|
||||
def render_google_shortcut(path):
|
||||
url = google_shortcut_url(path)
|
||||
label = SHORTCUT_TYPES[Path(path).suffix.lower()][1]
|
||||
return ('''<!doctype html><html lang="fr"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style>
|
||||
*{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;background:#f4f6fa;color:#262d3d;font:15px/1.5 system-ui,sans-serif;padding:24px}
|
||||
main{width:min(100%,520px);text-align:center;background:#fff;border:1px solid #e1e5f0;border-radius:18px;padding:36px 24px;box-shadow:0 8px 25px #1321420d}
|
||||
h1{font-size:22px}p{color:#657082}a{display:inline-block;margin-top:14px;background:#465a9a;color:#fff;padding:12px 20px;border-radius:10px;text-decoration:none;font-weight:600}</style></head><body><main>
|
||||
<h1>''' + escape(label) + '''</h1><p>Ce fichier est un raccourci Google. Son contenu reste hébergé sur Google Drive.</p>
|
||||
<a href="''' + escape(url, quote=True) + '''" target="_blank" rel="noopener noreferrer">Ouvrir sur Google</a>
|
||||
<p>L'accès dépend des autorisations du document Google.</p></main></body></html>''')
|
||||
36
backend/drive/test_google_shortcuts.py
Normal file
36
backend/drive/test_google_shortcuts.py
Normal file
@ -0,0 +1,36 @@
|
||||
"""No Google account is needed to validate safe link rendering."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from unittest import TestCase
|
||||
from .google_shortcuts import google_shortcut_url, render_google_shortcut
|
||||
|
||||
class GoogleShortcutTests(TestCase):
|
||||
def check(self, ext, url, accepted):
|
||||
with TemporaryDirectory() as temp:
|
||||
path = Path(temp) / f'example{ext}'
|
||||
path.write_text(json.dumps({'url': url}), encoding='utf-8')
|
||||
if accepted:
|
||||
self.assertEqual(google_shortcut_url(path),url)
|
||||
html = render_google_shortcut(path)
|
||||
self.assertIn('Ouvrir sur Google', html)
|
||||
self.assertIn('rel="noopener noreferrer"',html)
|
||||
else:
|
||||
with self.assertRaises(ValueError): google_shortcut_url(path)
|
||||
|
||||
def test_google_shortcuts(self):
|
||||
self.check('.gdoc', 'https://docs.google.com/document/d/AbcDefGhijkLMN123/edit', True)
|
||||
self.check('.gsheet', 'https://docs.google.com/spreadsheets/d/AbcDefGhijkLMN123/edit', True)
|
||||
self.check('.gslides', 'https://docs.google.com/presentation/d/AbcDefGhijkLMN123/edit', True)
|
||||
self.check('.gdoc', 'https://docs.google.com/spreadsheets/d/AbcDefGhijkLMN123/edit', False)
|
||||
self.check('.gdoc', 'https://docs.google.com.evil.test/document/d/AbcDefGhijkLMN123/edit', False)
|
||||
self.check('.gdoc', 'http://docs.google.com/document/d/AbcDefGhijkLMN123/edit', False)
|
||||
self.check('.gdoc', 'https://drive.google.com/open?id=AbcDefGhijkLMN123', True)
|
||||
|
||||
def test_reject_invalid_contents(self):
|
||||
with TemporaryDirectory() as temp:
|
||||
path = Path(temp) / 'broken.gdoc'
|
||||
path.write_text('{broken',encoding='utf-8')
|
||||
with self.assertRaises(ValueError): google_shortcut_url(path)
|
||||
path.write_text('x'*70000,encoding='utf-8')
|
||||
with self.assertRaises(ValueError): google_shortcut_url(path)
|
||||
@ -13,6 +13,7 @@ from .spaces import storage_path
|
||||
from .permissions import item_rights
|
||||
from .word_preview import render_word_preview
|
||||
from .document_formats import ODF_TYPES, render_odf_preview
|
||||
from .google_shortcuts import SHORTCUT_TYPES, render_google_shortcut
|
||||
|
||||
IMAGE_TYPES = {'image/jpeg','image/png','image/gif','image/webp','image/avif','image/bmp'}
|
||||
VIDEO_TYPES = {'video/mp4','video/webm','video/ogg','video/quicktime','video/x-m4v'}
|
||||
@ -23,7 +24,7 @@ TEXT_TYPES = {'text/plain','text/csv','text/markdown','text/x-python','text/java
|
||||
|
||||
def safe_preview_type(item):
|
||||
mime = (mimetypes.guess_type(item.name)[0] or item.mime_type or '').lower()
|
||||
if item.name.lower().endswith(('.docx', '.odt', '.ods', '.odp')):
|
||||
if item.name.lower().endswith(('.docx', '.odt', '.ods', '.odp', *SHORTCUT_TYPES)):
|
||||
return 'text/html; charset=utf-8'
|
||||
if mime in IMAGE_TYPES | VIDEO_TYPES | AUDIO_TYPES | {'application/pdf'}:
|
||||
return mime
|
||||
@ -76,14 +77,15 @@ def content(request,item_id):
|
||||
if not path.is_file():raise Http404
|
||||
size=path.stat().st_size
|
||||
content_type=media if inline=='1' else 'application/octet-stream'
|
||||
if inline=='1' and item.name.lower().endswith(('.docx', '.odt', '.ods', '.odp')):
|
||||
if inline=='1' and item.name.lower().endswith(('.docx', '.odt', '.ods', '.odp', *SHORTCUT_TYPES)):
|
||||
try:
|
||||
html = render_word_preview(path) if item.name.lower().endswith('.docx') else render_odf_preview(path)
|
||||
html = (render_google_shortcut(path) if path.suffix.lower() in SHORTCUT_TYPES else
|
||||
render_word_preview(path) if item.name.lower().endswith('.docx') else render_odf_preview(path))
|
||||
except ValueError:
|
||||
raise Http404
|
||||
response = HttpResponse(html if request.method=='GET' else '', content_type='text/html; charset=utf-8')
|
||||
response['Cache-Control'] = 'private, no-store'
|
||||
response['Content-Security-Policy'] = "sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data:"
|
||||
response['Content-Security-Policy'] = ("sandbox allow-popups allow-popups-to-escape-sandbox; default-src 'none'; style-src 'unsafe-inline'" if path.suffix.lower() in SHORTCUT_TYPES else "sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data:")
|
||||
response['X-Content-Type-Options'] = 'nosniff'
|
||||
return response
|
||||
name=item.name
|
||||
|
||||
@ -19,6 +19,7 @@ from .services import error, get_item, readable, writable, chain, item_info
|
||||
from .spaces import storage_path
|
||||
from .word_preview import render_word_preview
|
||||
from .document_formats import render_odf_preview
|
||||
from .google_shortcuts import SHORTCUT_TYPES, render_google_shortcut
|
||||
from .permissions import item_rights, assert_item_delegation, available_item_share_rights, PRESETS
|
||||
from .views_content import safe_preview_type, read_bytes
|
||||
|
||||
@ -178,14 +179,15 @@ def public_file(request,token,item_id):
|
||||
if inline and not preview:return error('Aperçu indisponible pour ce format.',415)
|
||||
content_type=preview if inline else 'application/octet-stream'
|
||||
size=path.stat().st_size
|
||||
if inline and item.name.lower().endswith(('.docx', '.odt', '.ods', '.odp')):
|
||||
if inline and item.name.lower().endswith(('.docx', '.odt', '.ods', '.odp', *SHORTCUT_TYPES)):
|
||||
try:
|
||||
html = render_word_preview(path) if item.name.lower().endswith('.docx') else render_odf_preview(path)
|
||||
html = (render_google_shortcut(path) if path.suffix.lower() in SHORTCUT_TYPES else
|
||||
render_word_preview(path) if item.name.lower().endswith('.docx') else render_odf_preview(path))
|
||||
except ValueError:
|
||||
raise Http404
|
||||
response = HttpResponse(html if request.method=='GET' else '', content_type='text/html; charset=utf-8')
|
||||
response['Cache-Control'] = 'no-store'
|
||||
response['Content-Security-Policy'] = "sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data:"
|
||||
response['Content-Security-Policy'] = ("sandbox allow-popups allow-popups-to-escape-sandbox; default-src 'none'; style-src 'unsafe-inline'" if path.suffix.lower() in SHORTCUT_TYPES else "sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data:")
|
||||
response['X-Content-Type-Options'] = 'nosniff'
|
||||
response['X-Frame-Options'] = 'SAMEORIGIN'
|
||||
response['Referrer-Policy'] = 'no-referrer'
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import {
|
||||
ChevronRight, Clock3, FolderOpen, Grid2X2, List as ListIcon, Trash2,
|
||||
LoaderCircle, Search, Upload,
|
||||
@ -105,7 +106,7 @@ export default function DriveExplorer({ controller, user }) {
|
||||
{error && <div className="mb-3"><Banner text={error} close={() => setError('')} /></div>}
|
||||
{note && <div className="mb-3"><Banner text={note} close={() => setNote('')} /></div>}
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto rounded-2xl border bg-card shadow-sm">
|
||||
<ScrollArea className="min-h-0 flex-1 rounded-2xl border bg-card shadow-sm">
|
||||
{loading ? (
|
||||
<div className="flex h-full min-h-72 items-center justify-center gap-3 text-sm text-muted-foreground">
|
||||
<LoaderCircle className="size-5 animate-spin" /> Chargement des fichiers…
|
||||
@ -135,7 +136,7 @@ export default function DriveExplorer({ controller, user }) {
|
||||
) : (
|
||||
<FileGrid items={display} onOpen={isTrash ? () => {} : openEntry} onActions={startAction} onContext={openContext} />
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<AlertDialog open={confirmEmptyTrash && isTrash} onOpenChange={setConfirmEmptyTrash}>
|
||||
<AlertDialogContent>
|
||||
|
||||
@ -2,9 +2,10 @@ import { useCallback, useEffect, useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import ContextActions, {spaceOptions, mobileDots} from './ContextActions'
|
||||
import { ChevronDown, ChevronRight, MoreHorizontal, Plus } from 'lucide-react'
|
||||
import { Database, FolderPlus, FilePlus2, FileText, ExternalLink, LogOut, FolderLock, FolderOpen, X, Users, Share2 } from 'lucide-react'
|
||||
import { localFileGroups, googleFileTypes } from '@/config/creatable-files'
|
||||
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent, DropdownMenuLabel } from '@/components/ui/dropdown-menu'
|
||||
import { Database, FolderPlus, FilePlus2, FileText, LogOut, FolderLock, FolderOpen, X, Users, Share2 } from 'lucide-react'
|
||||
import { localFileGroups } from '@/config/creatable-files'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent } from '@/components/ui/dropdown-menu'
|
||||
import { sections } from '@/config/navigation'
|
||||
import { fmtSize } from '@/lib/drive-format'
|
||||
|
||||
@ -94,11 +95,7 @@ export default function DriveSidebar({ controller, user, onLogout }) {
|
||||
{group.types.map(type => <DropdownMenuItem key={type.extension} onClick={() => newFile(type)}>{type.label} <span className="ml-auto text-xs text-muted-foreground">.{type.extension}</span></DropdownMenuItem>)}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>)}
|
||||
<DropdownMenuSeparator/>
|
||||
<DropdownMenuLabel>Google Drive · création externe</DropdownMenuLabel>
|
||||
{googleFileTypes.map(type => <DropdownMenuItem key={type.url} onClick={() => window.open(type.url, '_blank', 'noopener,noreferrer')}>
|
||||
{type.label} <ExternalLink className="ml-auto size-3.5"/>
|
||||
</DropdownMenuItem>)}
|
||||
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuSeparator/>
|
||||
@ -107,7 +104,7 @@ export default function DriveSidebar({ controller, user, onLogout }) {
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 overflow-y-auto px-3 pb-4">
|
||||
<ScrollArea className="min-h-0 flex-1"><div className="px-3 pb-4">
|
||||
{personal && <button type="button" onClick={()=>switchSpace(personal.id)}
|
||||
className={`mb-4 flex min-h-11 w-full items-center gap-3 rounded-xl px-3 text-left text-sm font-semibold ${activeSpaceId===personal.id ? 'bg-accent text-primary' : 'text-muted-foreground hover:bg-muted'}`}>
|
||||
<FolderLock className="size-[19px] shrink-0"/><span className="truncate">Mes fichiers</span>
|
||||
@ -139,7 +136,7 @@ export default function DriveSidebar({ controller, user, onLogout }) {
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</div></ScrollArea>
|
||||
|
||||
<div className="mt-auto space-y-3 p-4">
|
||||
{view === 'files' && activeSpace && <div className="rounded-2xl border bg-muted/45 p-4">
|
||||
|
||||
@ -22,6 +22,7 @@ function Preview({ item, url, onError, fullscreen }) {
|
||||
const textHeight = fullscreen ? 'h-full min-h-0' : 'h-[65dvh]'
|
||||
|
||||
if ((mime.startsWith('image/') && mime !== 'image/svg+xml') || /\.(jpe?g|png|gif|webp|avif|bmp)$/.test(name)) return <img src={url} onError={onError} alt={item.name} className={`${mediaHeight} max-w-full object-contain`} />
|
||||
if (/\.(gdoc|gsheet|gslides)$/.test(name)) return <iframe title={item.name} src={url} onError={onError} sandbox="allow-popups allow-popups-to-escape-sandbox" className={`${frameHeight} min-w-0 w-full flex-1 rounded-md bg-white`}/>
|
||||
if (/\.(docx|odt|ods|odp)$/.test(name)) return <iframe title={item.name} src={url} onError={onError} sandbox="" className={`${frameHeight} min-w-0 w-full flex-1 rounded-md bg-white`} />
|
||||
if (mime === 'application/pdf' || name.endsWith('.pdf')) return <iframe title={item.name} src={url} onError={onError} className={`block ${frameHeight} min-w-0 w-full flex-1 rounded-md bg-white`} />
|
||||
if (mime.startsWith('video/') || /\.(mp4|webm|ogv|mov|m4v)$/.test(name)) return <video key={url} onError={onError} controls playsInline preload="metadata" className={`${mediaHeight} w-full`} src={url}>Vidéo non supportée par le navigateur.</video>
|
||||
|
||||
@ -36,14 +36,14 @@ function ScrollBar({
|
||||
data-orientation={orientation}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
|
||||
"flex touch-none rounded-full bg-muted/40 p-px transition-colors select-none hover:bg-muted/60 data-horizontal:h-2.5 data-horizontal:flex-col data-vertical:h-full data-vertical:w-2.5",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Thumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="relative flex-1 rounded-full bg-border"
|
||||
className="relative flex-1 rounded-full bg-muted-foreground/35 transition-colors hover:bg-primary/55"
|
||||
/>
|
||||
</ScrollAreaPrimitive.Scrollbar>
|
||||
)
|
||||
|
||||
@ -24,11 +24,3 @@ export const localFileGroups = [
|
||||
{ label: 'Impress', extension: 'odp', suggestedName: 'Présentation' },
|
||||
] },
|
||||
]
|
||||
|
||||
// Google-native Docs/Sheets/Slides are cloud objects, not blank local files.
|
||||
// These links create them on Google's service; they are not saved to GoodDrive.
|
||||
export const googleFileTypes = [
|
||||
{ label: 'Google Docs (externe)', url: 'https://docs.google.com/document/u/0/create' },
|
||||
{ label: 'Google Sheets (externe)', url: 'https://docs.google.com/spreadsheets/u/0/create' },
|
||||
{ label: 'Google Slides (externe)', url: 'https://docs.google.com/presentation/u/0/create' },
|
||||
]
|
||||
|
||||
@ -47,3 +47,18 @@
|
||||
.gooddrive-preview-dialog[data-preview-fullscreen="true"] {
|
||||
transition: width 120ms ease, height 120ms ease;
|
||||
}
|
||||
|
||||
/* Scrollbars consistent with the shadcn theme, including native overflow panels. */
|
||||
:root { scrollbar-color: color-mix(in oklab, var(--muted-foreground) 42%, transparent) transparent; scrollbar-width: thin; }
|
||||
*::-webkit-scrollbar { width: 9px; height: 9px; }
|
||||
*::-webkit-scrollbar-track { background: transparent; }
|
||||
*::-webkit-scrollbar-thumb {
|
||||
border: 2px solid transparent;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in oklab, var(--muted-foreground) 35%, transparent);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background: color-mix(in oklab, var(--primary) 62%, transparent);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
@ -1,12 +1,13 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Users, LoaderCircle, Trash2, KeyRound, Menu } from 'lucide-react'
|
||||
import { Users, LoaderCircle, Trash2, KeyRound, Menu, Search, Moon, Sun, RefreshCcw } from 'lucide-react'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { drive, message } from '@/api/client'
|
||||
import PermissionsEditor from '@/components/drive/PermissionsEditor'
|
||||
|
||||
export default function AdminUsersPage({onOpenSidebar}){
|
||||
export default function AdminUsersPage({onOpenSidebar, dark, setDark}){
|
||||
const [query,setQuery] = useState('')
|
||||
const [record,setRecord] = useState({users:[]})
|
||||
const [busy,setBusy] = useState(true)
|
||||
@ -63,13 +64,32 @@ export default function AdminUsersPage({onOpenSidebar}){
|
||||
catch(e){setDeleteError(message(e))}
|
||||
finally{setDeleting(false)}
|
||||
}
|
||||
return <div className="min-h-0 flex-1 overflow-auto p-4 sm:p-7">
|
||||
<div className="mx-auto max-w-5xl space-y-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button type="button" variant="outline" size="icon" className="shrink-0 md:hidden" aria-label="Ouvrir le menu" onClick={onOpenSidebar}><Menu className="size-5" /></Button>
|
||||
<h1 className="flex items-center gap-3 text-2xl font-semibold"><Users className="hidden sm:block"/> Utilisateurs</h1>
|
||||
return <>
|
||||
<header className="flex h-[76px] shrink-0 items-center justify-between gap-3 border-b bg-card/70 px-4 sm:px-7">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<Button type="button" variant="ghost" size="icon" className="shrink-0 md:hidden" aria-label="Ouvrir le menu" onClick={onOpenSidebar}><Menu className="size-5"/></Button>
|
||||
<h2 className="truncate text-base font-semibold sm:text-lg">Utilisateurs</h2>
|
||||
</div>
|
||||
<Input placeholder="Rechercher par nom, identifiant ou e-mail…" value={query} onChange={e=>setQuery(e.target.value)}/>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative hidden w-64 lg:block">
|
||||
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"/>
|
||||
<Input aria-label="Rechercher un utilisateur" placeholder="Rechercher un utilisateur…" className="h-9 pl-9" value={query} onChange={e=>setQuery(e.target.value)}/>
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="icon" title="Changer le thème" aria-label="Changer le thème" onClick={()=>setDark(value=>!value)}>{dark?<Sun/>:<Moon/>}</Button>
|
||||
<Button type="button" variant="ghost" size="icon" title="Actualiser" aria-label="Actualiser les utilisateurs" onClick={refresh}><RefreshCcw/></Button>
|
||||
</div>
|
||||
</header>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<section className="px-4 pb-4 pt-6 sm:px-7 sm:pb-7">
|
||||
<div className="mb-4 lg:hidden">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"/>
|
||||
<Input aria-label="Rechercher un utilisateur" placeholder="Rechercher un utilisateur…" className="pl-9" value={query} onChange={e=>setQuery(e.target.value)}/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-5 flex flex-wrap items-end justify-between gap-3">
|
||||
<div><h1 className="text-2xl font-semibold tracking-tight">Utilisateurs</h1><p className="mt-1 text-sm text-muted-foreground">{busy?'Chargement…':`${record.users.length} utilisateur${record.users.length>1?'s':''}`}</p></div>
|
||||
</div>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
{busy?<LoaderCircle className="size-6 animate-spin"/>:<div className="overflow-x-auto rounded-xl border bg-card">
|
||||
<table className="w-full min-w-[650px] text-left text-sm"><thead className="bg-muted/50 text-muted-foreground"><tr><th className="p-3">Utilisateur</th><th className="p-3">Identifiant</th><th className="p-3">E-mail</th><th className="p-3">Espace attribué</th><th className="p-3">Statut</th><th className="p-3 text-right">Actions</th></tr></thead>
|
||||
@ -85,7 +105,8 @@ export default function AdminUsersPage({onOpenSidebar}){
|
||||
{!record.users.length && <p className="p-4 text-sm text-muted-foreground">Aucun utilisateur.</p>}
|
||||
</div>}
|
||||
{record.limited && <p className="text-sm text-muted-foreground">200 résultats affichés. Précise la recherche.</p>}
|
||||
</div>
|
||||
</section>
|
||||
</ScrollArea>
|
||||
<Dialog open={!!accessUser} onOpenChange={open=>{if(!open&&!accessBusy)setAccessUser(null)}}>
|
||||
<DialogContent className="sm:max-w-xl">
|
||||
<DialogHeader><DialogTitle>Accès de @{accessUser?.username}</DialogTitle>
|
||||
@ -116,7 +137,7 @@ export default function AdminUsersPage({onOpenSidebar}){
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
|
||||
function AccessPermissionsRow({access,busy,save,revoke}) {
|
||||
|
||||
@ -19,7 +19,7 @@ export default function DrivePage({user, onLogout}) {
|
||||
{sidebar && <button className="fixed inset-0 z-30 bg-black/40 md:hidden" aria-label="Fermer le menu" onClick={() => setSidebar(false)} />}
|
||||
<DriveSidebar controller={controller} user={user} onLogout={onLogout} />
|
||||
<main className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
{controller.view === 'users' && user.is_admin ? <AdminUsersPage onOpenSidebar={() => setSidebar(true)} /> : <>
|
||||
{controller.view === 'users' && user.is_admin ? <AdminUsersPage onOpenSidebar={() => setSidebar(true)} dark={controller.dark} setDark={controller.setDark} /> : <>
|
||||
<DriveToolbar controller={controller} />
|
||||
<DriveExplorer controller={controller} user={user} />
|
||||
</>}
|
||||
|
||||
@ -7,6 +7,7 @@ import { fmtSize } from '@/lib/drive-format'
|
||||
function typeOf(item) {
|
||||
const n = (item.name || '').toLowerCase(), m=(item.mime_type || '').toLowerCase()
|
||||
if(m.startsWith('image/') && m!=='image/svg+xml' || /\.(png|jpe?g|gif|webp|bmp|avif)$/.test(n)) return 'image'
|
||||
if(/\.(gdoc|gsheet|gslides)$/.test(n))return 'google'
|
||||
if(/\.(docx|odt|ods|odp)$/.test(n))return 'word'
|
||||
if(m==='application/pdf' || n.endsWith('.pdf'))return 'pdf'
|
||||
if(m.startsWith('video/') || /\.(mp4|webm|mov|m4v|ogv)$/.test(n))return 'video'
|
||||
@ -20,6 +21,7 @@ function Viewer({item,token,fullscreen}) {
|
||||
const mediaHeight=fullscreen?'max-h-full':'max-h-full'
|
||||
const frameHeight='h-full min-h-0'
|
||||
if(kind==='image')return <img className={`mx-auto ${mediaHeight} max-w-full rounded-lg object-contain`} alt={item.name} src={src}/>
|
||||
if(kind==='google')return <iframe title={item.name} src={src} sandbox="allow-popups allow-popups-to-escape-sandbox" className={`${frameHeight} min-w-0 w-full flex-1 rounded-xl bg-white`}/>
|
||||
if(kind==='word')return <iframe title={item.name} src={src} sandbox="" className={`${frameHeight} min-w-0 w-full flex-1 rounded-xl bg-white`}/>
|
||||
if(kind==='pdf')return <iframe title={item.name} src={src} className={`${frameHeight} min-w-0 w-full flex-1 rounded-xl bg-white`}/>
|
||||
if(kind==='video')return <video controls playsInline preload="metadata" src={src} className={`${mediaHeight} w-full rounded-xl`}/>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user