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 .permissions import item_rights
|
||||||
from .word_preview import render_word_preview
|
from .word_preview import render_word_preview
|
||||||
from .document_formats import ODF_TYPES, render_odf_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'}
|
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'}
|
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):
|
def safe_preview_type(item):
|
||||||
mime = (mimetypes.guess_type(item.name)[0] or item.mime_type or '').lower()
|
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'
|
return 'text/html; charset=utf-8'
|
||||||
if mime in IMAGE_TYPES | VIDEO_TYPES | AUDIO_TYPES | {'application/pdf'}:
|
if mime in IMAGE_TYPES | VIDEO_TYPES | AUDIO_TYPES | {'application/pdf'}:
|
||||||
return mime
|
return mime
|
||||||
@ -76,14 +77,15 @@ def content(request,item_id):
|
|||||||
if not path.is_file():raise Http404
|
if not path.is_file():raise Http404
|
||||||
size=path.stat().st_size
|
size=path.stat().st_size
|
||||||
content_type=media if inline=='1' else 'application/octet-stream'
|
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:
|
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:
|
except ValueError:
|
||||||
raise Http404
|
raise Http404
|
||||||
response = HttpResponse(html if request.method=='GET' else '', content_type='text/html; charset=utf-8')
|
response = HttpResponse(html if request.method=='GET' else '', content_type='text/html; charset=utf-8')
|
||||||
response['Cache-Control'] = 'private, no-store'
|
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'
|
response['X-Content-Type-Options'] = 'nosniff'
|
||||||
return response
|
return response
|
||||||
name=item.name
|
name=item.name
|
||||||
|
|||||||
@ -19,6 +19,7 @@ from .services import error, get_item, readable, writable, chain, item_info
|
|||||||
from .spaces import storage_path
|
from .spaces import storage_path
|
||||||
from .word_preview import render_word_preview
|
from .word_preview import render_word_preview
|
||||||
from .document_formats import render_odf_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 .permissions import item_rights, assert_item_delegation, available_item_share_rights, PRESETS
|
||||||
from .views_content import safe_preview_type, read_bytes
|
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)
|
if inline and not preview:return error('Aperçu indisponible pour ce format.',415)
|
||||||
content_type=preview if inline else 'application/octet-stream'
|
content_type=preview if inline else 'application/octet-stream'
|
||||||
size=path.stat().st_size
|
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:
|
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:
|
except ValueError:
|
||||||
raise Http404
|
raise Http404
|
||||||
response = HttpResponse(html if request.method=='GET' else '', content_type='text/html; charset=utf-8')
|
response = HttpResponse(html if request.method=='GET' else '', content_type='text/html; charset=utf-8')
|
||||||
response['Cache-Control'] = 'no-store'
|
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-Content-Type-Options'] = 'nosniff'
|
||||||
response['X-Frame-Options'] = 'SAMEORIGIN'
|
response['X-Frame-Options'] = 'SAMEORIGIN'
|
||||||
response['Referrer-Policy'] = 'no-referrer'
|
response['Referrer-Policy'] = 'no-referrer'
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import React, { useCallback, useEffect, useState } from 'react'
|
import React, { useCallback, useEffect, useState } from 'react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
import {
|
import {
|
||||||
ChevronRight, Clock3, FolderOpen, Grid2X2, List as ListIcon, Trash2,
|
ChevronRight, Clock3, FolderOpen, Grid2X2, List as ListIcon, Trash2,
|
||||||
LoaderCircle, Search, Upload,
|
LoaderCircle, Search, Upload,
|
||||||
@ -105,7 +106,7 @@ export default function DriveExplorer({ controller, user }) {
|
|||||||
{error && <div className="mb-3"><Banner text={error} close={() => setError('')} /></div>}
|
{error && <div className="mb-3"><Banner text={error} close={() => setError('')} /></div>}
|
||||||
{note && <div className="mb-3"><Banner text={note} close={() => setNote('')} /></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 ? (
|
{loading ? (
|
||||||
<div className="flex h-full min-h-72 items-center justify-center gap-3 text-sm text-muted-foreground">
|
<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…
|
<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} />
|
<FileGrid items={display} onOpen={isTrash ? () => {} : openEntry} onActions={startAction} onContext={openContext} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</ScrollArea>
|
||||||
|
|
||||||
<AlertDialog open={confirmEmptyTrash && isTrash} onOpenChange={setConfirmEmptyTrash}>
|
<AlertDialog open={confirmEmptyTrash && isTrash} onOpenChange={setConfirmEmptyTrash}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
|
|||||||
@ -2,9 +2,10 @@ import { useCallback, useEffect, useState } from 'react'
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import ContextActions, {spaceOptions, mobileDots} from './ContextActions'
|
import ContextActions, {spaceOptions, mobileDots} from './ContextActions'
|
||||||
import { ChevronDown, ChevronRight, MoreHorizontal, Plus } from 'lucide-react'
|
import { ChevronDown, ChevronRight, MoreHorizontal, Plus } from 'lucide-react'
|
||||||
import { Database, FolderPlus, FilePlus2, FileText, ExternalLink, LogOut, FolderLock, FolderOpen, X, Users, Share2 } from 'lucide-react'
|
import { Database, FolderPlus, FilePlus2, FileText, LogOut, FolderLock, FolderOpen, X, Users, Share2 } from 'lucide-react'
|
||||||
import { localFileGroups, googleFileTypes } from '@/config/creatable-files'
|
import { localFileGroups } from '@/config/creatable-files'
|
||||||
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent, DropdownMenuLabel } from '@/components/ui/dropdown-menu'
|
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 { sections } from '@/config/navigation'
|
||||||
import { fmtSize } from '@/lib/drive-format'
|
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>)}
|
{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>
|
</DropdownMenuSubContent>
|
||||||
</DropdownMenuSub>)}
|
</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>
|
</DropdownMenuSubContent>
|
||||||
</DropdownMenuSub>
|
</DropdownMenuSub>
|
||||||
<DropdownMenuSeparator/>
|
<DropdownMenuSeparator/>
|
||||||
@ -107,7 +104,7 @@ export default function DriveSidebar({ controller, user, onLogout }) {
|
|||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</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)}
|
{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'}`}>
|
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>
|
<FolderLock className="size-[19px] shrink-0"/><span className="truncate">Mes fichiers</span>
|
||||||
@ -139,7 +136,7 @@ export default function DriveSidebar({ controller, user, onLogout }) {
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div></ScrollArea>
|
||||||
|
|
||||||
<div className="mt-auto space-y-3 p-4">
|
<div className="mt-auto space-y-3 p-4">
|
||||||
{view === 'files' && activeSpace && <div className="rounded-2xl border bg-muted/45 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]'
|
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 ((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 (/\.(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 === '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>
|
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}
|
data-orientation={orientation}
|
||||||
orientation={orientation}
|
orientation={orientation}
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ScrollAreaPrimitive.Thumb
|
<ScrollAreaPrimitive.Thumb
|
||||||
data-slot="scroll-area-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>
|
</ScrollAreaPrimitive.Scrollbar>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -24,11 +24,3 @@ export const localFileGroups = [
|
|||||||
{ label: 'Impress', extension: 'odp', suggestedName: 'Présentation' },
|
{ 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"] {
|
.gooddrive-preview-dialog[data-preview-fullscreen="true"] {
|
||||||
transition: width 120ms ease, height 120ms ease;
|
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 { 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 { Input } from '@/components/ui/input'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
import { drive, message } from '@/api/client'
|
import { drive, message } from '@/api/client'
|
||||||
import PermissionsEditor from '@/components/drive/PermissionsEditor'
|
import PermissionsEditor from '@/components/drive/PermissionsEditor'
|
||||||
|
|
||||||
export default function AdminUsersPage({onOpenSidebar}){
|
export default function AdminUsersPage({onOpenSidebar, dark, setDark}){
|
||||||
const [query,setQuery] = useState('')
|
const [query,setQuery] = useState('')
|
||||||
const [record,setRecord] = useState({users:[]})
|
const [record,setRecord] = useState({users:[]})
|
||||||
const [busy,setBusy] = useState(true)
|
const [busy,setBusy] = useState(true)
|
||||||
@ -63,13 +64,32 @@ export default function AdminUsersPage({onOpenSidebar}){
|
|||||||
catch(e){setDeleteError(message(e))}
|
catch(e){setDeleteError(message(e))}
|
||||||
finally{setDeleting(false)}
|
finally{setDeleting(false)}
|
||||||
}
|
}
|
||||||
return <div className="min-h-0 flex-1 overflow-auto p-4 sm:p-7">
|
return <>
|
||||||
<div className="mx-auto max-w-5xl space-y-5">
|
<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 items-center gap-3">
|
<div className="flex min-w-0 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>
|
<Button type="button" variant="ghost" 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>
|
<h2 className="truncate text-base font-semibold sm:text-lg">Utilisateurs</h2>
|
||||||
|
</div>
|
||||||
|
<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>
|
</div>
|
||||||
<Input placeholder="Rechercher par nom, identifiant ou e-mail…" value={query} onChange={e=>setQuery(e.target.value)}/>
|
|
||||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
{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">
|
{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>
|
<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>}
|
{!record.users.length && <p className="p-4 text-sm text-muted-foreground">Aucun utilisateur.</p>}
|
||||||
</div>}
|
</div>}
|
||||||
{record.limited && <p className="text-sm text-muted-foreground">200 résultats affichés. Précise la recherche.</p>}
|
{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)}}>
|
<Dialog open={!!accessUser} onOpenChange={open=>{if(!open&&!accessBusy)setAccessUser(null)}}>
|
||||||
<DialogContent className="sm:max-w-xl">
|
<DialogContent className="sm:max-w-xl">
|
||||||
<DialogHeader><DialogTitle>Accès de @{accessUser?.username}</DialogTitle>
|
<DialogHeader><DialogTitle>Accès de @{accessUser?.username}</DialogTitle>
|
||||||
@ -116,7 +137,7 @@ export default function AdminUsersPage({onOpenSidebar}){
|
|||||||
</form>
|
</form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</>
|
||||||
}
|
}
|
||||||
|
|
||||||
function AccessPermissionsRow({access,busy,save,revoke}) {
|
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)} />}
|
{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} />
|
<DriveSidebar controller={controller} user={user} onLogout={onLogout} />
|
||||||
<main className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
<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} />
|
<DriveToolbar controller={controller} />
|
||||||
<DriveExplorer controller={controller} user={user} />
|
<DriveExplorer controller={controller} user={user} />
|
||||||
</>}
|
</>}
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { fmtSize } from '@/lib/drive-format'
|
|||||||
function typeOf(item) {
|
function typeOf(item) {
|
||||||
const n = (item.name || '').toLowerCase(), m=(item.mime_type || '').toLowerCase()
|
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(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(/\.(docx|odt|ods|odp)$/.test(n))return 'word'
|
||||||
if(m==='application/pdf' || n.endsWith('.pdf'))return 'pdf'
|
if(m==='application/pdf' || n.endsWith('.pdf'))return 'pdf'
|
||||||
if(m.startsWith('video/') || /\.(mp4|webm|mov|m4v|ogv)$/.test(n))return 'video'
|
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 mediaHeight=fullscreen?'max-h-full':'max-h-full'
|
||||||
const frameHeight='h-full min-h-0'
|
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==='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==='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==='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`}/>
|
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