@brftech/filex-core 0.42.0 → 0.42.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{ArchiveViewer-CGl_J_qE.js → ArchiveViewer-BUQWLhwX.js} +2 -2
- package/dist/{ArchiveViewer-CGl_J_qE.js.map → ArchiveViewer-BUQWLhwX.js.map} +1 -1
- package/dist/{CsvViewer-D-na6SEG.js → CsvViewer-Bg2_aqzc.js} +2 -2
- package/dist/{CsvViewer-D-na6SEG.js.map → CsvViewer-Bg2_aqzc.js.map} +1 -1
- package/dist/{DrawioViewer-BFkTSrNc.js → DrawioViewer-CC6rJXeP.js} +2 -2
- package/dist/{DrawioViewer-BFkTSrNc.js.map → DrawioViewer-CC6rJXeP.js.map} +1 -1
- package/dist/{EpubViewer-BfRE0DTL.js → EpubViewer-CAoTSXTO.js} +2 -2
- package/dist/{EpubViewer-BfRE0DTL.js.map → EpubViewer-CAoTSXTO.js.map} +1 -1
- package/dist/{IpynbViewer-BpBlICH-.js → IpynbViewer-D0GwGpnE.js} +2 -2
- package/dist/{IpynbViewer-BpBlICH-.js.map → IpynbViewer-D0GwGpnE.js.map} +1 -1
- package/dist/{MermaidViewer-Ry5Ma463.js → MermaidViewer-DGwAvRZg.js} +2 -2
- package/dist/{MermaidViewer-Ry5Ma463.js.map → MermaidViewer-DGwAvRZg.js.map} +1 -1
- package/dist/{PsdViewer-BmTlq1Gh.js → PsdViewer-DhGweA0m.js} +2 -2
- package/dist/{PsdViewer-BmTlq1Gh.js.map → PsdViewer-DhGweA0m.js.map} +1 -1
- package/dist/{TiffViewer-DE5bKDDx.js → TiffViewer-D0AurvAh.js} +2 -2
- package/dist/{TiffViewer-DE5bKDDx.js.map → TiffViewer-D0AurvAh.js.map} +1 -1
- package/dist/{Viewer3D-DefcNYOm.js → Viewer3D-CdZeK4j4.js} +2 -2
- package/dist/{Viewer3D-DefcNYOm.js.map → Viewer3D-CdZeK4j4.js.map} +1 -1
- package/dist/filex-core.js +1 -1
- package/dist/filex-core.umd.cjs +50 -50
- package/dist/filex-core.umd.cjs.map +1 -1
- package/dist/{index-B0N5qiyj.js → index-BArI-B1M.js} +9818 -9725
- package/dist/index-BArI-B1M.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/FileExplorer.vue +165 -90
- package/src/components/HomeView.vue +6 -3
- package/src/components/RecentlyOpened.vue +16 -2
- package/src/components/S3KeysPanel.vue +33 -27
- package/src/components/SSHKeysPanel.vue +29 -25
- package/src/components/SideNav.vue +22 -0
- package/src/components/TokensPanel.vue +26 -22
- package/src/composables/useScrolledX.ts +21 -0
- package/src/lib/nodeRow.ts +106 -0
- package/src/locales/en.ts +8 -2
- package/src/locales/tr.ts +4 -3
- package/src/styles/base.css +24 -0
- package/src/types/FileNode.ts +8 -0
- package/dist/index-B0N5qiyj.js.map +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brftech/filex-core",
|
|
3
|
-
"version": "0.42.
|
|
3
|
+
"version": "0.42.2",
|
|
4
4
|
"description": "filex core — Vue 3 source of truth for the filex file manager (FileExplorer + ConnectionsPanel SFCs, composables, types)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/filex-core.umd.cjs",
|
package/src/FileExplorer.vue
CHANGED
|
@@ -161,6 +161,7 @@ import {
|
|
|
161
161
|
injectTrashRow,
|
|
162
162
|
hydrateTrashRow as hydrateTrashRowShared,
|
|
163
163
|
} from './lib/listing';
|
|
164
|
+
import { nodeRowToFileNode as nodeRowToFileNodePure } from './lib/nodeRow'; /* Recent / Starred / tag / Home rows — one shape, with `perm` + `read_only` */
|
|
164
165
|
import { iconFamilyFor, isStorageRow } from './lib/fileIcons'; /* pane:p1 — the storage-row predicate's one home */
|
|
165
166
|
import { actionIconSvg } from './lib/actionIcons'; /* inceleme:r1 — the drop overlay's mark, off the emoji font */
|
|
166
167
|
import { setNodeStarred } from './lib/star';
|
|
@@ -332,6 +333,10 @@ const files = ref<FileNode[]>([]);
|
|
|
332
333
|
// RBAC effective level for the current directory ('' = ACL not enforced on
|
|
333
334
|
// this storage → no gating). Drives which write/manage actions are offered.
|
|
334
335
|
const dirPerm = ref<string>('');
|
|
336
|
+
/** The listed folder sits on a read-only storage (`read_only` on the index
|
|
337
|
+
* response). Folded into permCanEdit so every write affordance — toolbar,
|
|
338
|
+
* sidebar menu, context menu, drop zone, editor mode — reads the same fact. */
|
|
339
|
+
const dirReadOnly = ref(false);
|
|
335
340
|
// The dead deep-link state: set to the requested path when a listing came
|
|
336
341
|
// back 404 (folder doesn't exist) or 403 (RBAC-hidden — rendered identically
|
|
337
342
|
// on purpose so a denied folder doesn't reveal that it exists). '' = none.
|
|
@@ -995,19 +1000,42 @@ function onNodeTagsChanged() {
|
|
|
995
1000
|
}
|
|
996
1001
|
|
|
997
1002
|
function onRecentOpen(entry: { id: number; storage_id?: number; path: string; name: string }) {
|
|
998
|
-
// RecentlyOpened emits the bare row
|
|
999
|
-
//
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1003
|
+
// RecentlyOpened emits the bare row. The same converter Recent / Starred /
|
|
1004
|
+
// a tag / Home use gives openNode a row with its storage-qualified path AND
|
|
1005
|
+
// its own `perm` + `read_only`, so the editor-vs-preview split reads the
|
|
1006
|
+
// row's level, not the folder's. The bare synthesis stays as the fallback
|
|
1007
|
+
// for a row the converter cannot address (no storage name at a
|
|
1008
|
+
// multi-storage root) — it was the only shape until 2026-09-19.
|
|
1009
|
+
const node =
|
|
1010
|
+
nodeRowToFileNode(entry as unknown as Record<string, unknown>) ??
|
|
1011
|
+
({
|
|
1012
|
+
type: 'file',
|
|
1013
|
+
path: entry.path,
|
|
1014
|
+
basename: entry.name,
|
|
1015
|
+
extension: (entry.name.split('.').pop() || '').toLowerCase(),
|
|
1016
|
+
id: entry.id,
|
|
1017
|
+
} as unknown as FileNode);
|
|
1007
1018
|
showRecents.value = false;
|
|
1008
1019
|
openNode(node);
|
|
1009
1020
|
}
|
|
1010
1021
|
|
|
1022
|
+
/**
|
|
1023
|
+
* Right-click on a row of the Recently-opened tray: the SAME menu a listing
|
|
1024
|
+
* row gets (owner: "CONTEXT MENÜ HER YERDE AYNI OLMALI"). The tray's row is
|
|
1025
|
+
* the raw node row the endpoint answers with, so it goes through
|
|
1026
|
+
* `nodeRowToFileNode` like Recent / Starred / a tag / Home do and arrives
|
|
1027
|
+
* with its own `perm` + `read_only`; `onContextTarget` then treats it like a
|
|
1028
|
+
* Home card — a target outside this pane's listing. The tray closes first,
|
|
1029
|
+
* as it does on open: it is a modal over the listing, and the verb the menu
|
|
1030
|
+
* runs (Rename, Move to…) opens a dialog of its own.
|
|
1031
|
+
*/
|
|
1032
|
+
function onRecentContext(row: Record<string, unknown>, ev: MouseEvent) {
|
|
1033
|
+
const node = nodeRowToFileNode(row);
|
|
1034
|
+
if (!node) return;
|
|
1035
|
+
showRecents.value = false;
|
|
1036
|
+
void onContextTarget(node, ev);
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1011
1039
|
// Resolution order for each external viewer: explicit config override → live
|
|
1012
1040
|
// backend probe. The probe is the source of truth: an operator can flip the
|
|
1013
1041
|
// service "on" but if last_check failed (state='error') we still hide the
|
|
@@ -1589,15 +1617,22 @@ function onToolbarSearch(v: string) {
|
|
|
1589
1617
|
* searches what is on screen.
|
|
1590
1618
|
*/
|
|
1591
1619
|
const driveScopeLabel = computed(() => {
|
|
1592
|
-
/*
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1620
|
+
/* ⚠ The field searches the WHOLE storage (`?action=search` filters by
|
|
1621
|
+
storage id only — see handlers/manager.go vfSearch), and at the virtual
|
|
1622
|
+
root every storage. Naming the open FOLDER here ("Search in Photos") was
|
|
1623
|
+
a lie the user found: results came from the whole storage. The scope is
|
|
1624
|
+
the storage; the folder-scoped box is FilterBar's "Filter in this
|
|
1625
|
+
folder…". Home, the virtual root and the cross-storage views (Recent,
|
|
1626
|
+
Starred, Shared, tags) have no single storage → the everywhere wording.
|
|
1627
|
+
⚠ `multiStorageRoot` is the MODE (the admin explorer is always in it), not
|
|
1628
|
+
the place: inside a storage the scope is that storage. Gating on the mode
|
|
1629
|
+
printed "Search all storages" on every admin folder (caught by
|
|
1630
|
+
e2e/tests/107 on the v0.42.2 chain). */
|
|
1631
|
+
if (navView.value || atVirtualRoot.value) return '';
|
|
1632
|
+
const name = adapter.value;
|
|
1633
|
+
if (!name) return '';
|
|
1634
|
+
const st = (props.config.storages ?? []).find((s) => s.name === name);
|
|
1635
|
+
return st?.label || name;
|
|
1601
1636
|
});
|
|
1602
1637
|
|
|
1603
1638
|
/**
|
|
@@ -1676,70 +1711,19 @@ async function loadQuota() {
|
|
|
1676
1711
|
}
|
|
1677
1712
|
|
|
1678
1713
|
/**
|
|
1679
|
-
* nodeRowToFileNode — the starred / recently-opened endpoints answer
|
|
1680
|
-
* node rows (relative `path`, numeric `storage_id`), not the listing
|
|
1681
|
-
*
|
|
1682
|
-
*
|
|
1683
|
-
*
|
|
1684
|
-
*
|
|
1685
|
-
*
|
|
1686
|
-
* install sends the user to a path in somebody else's drive.
|
|
1714
|
+
* nodeRowToFileNode — the starred / recently-opened / tag endpoints answer
|
|
1715
|
+
* with raw node rows (relative `path`, numeric `storage_id`), not the listing
|
|
1716
|
+
* shape. The conversion itself lives in `lib/nodeRow` (pure, unit-tested);
|
|
1717
|
+
* this binds it to the explorer's config. Every view that lists such rows —
|
|
1718
|
+
* Recent, Starred, a tag, the Home cards, the Recently-opened tray — goes
|
|
1719
|
+
* through here, so they all carry the same `perm` + `read_only` the context
|
|
1720
|
+
* menu gates its write verbs on.
|
|
1687
1721
|
*/
|
|
1688
|
-
/** tablo:t1 — an RFC3339 stamp from a node row as unix ms, or undefined. A
|
|
1689
|
-
* string we cannot parse is left undefined rather than turned into `NaN`,
|
|
1690
|
-
* which would print as "Invalid Date" and sort unpredictably. */
|
|
1691
|
-
function rowMillis(v: unknown): number | undefined {
|
|
1692
|
-
if (typeof v !== 'string' || !v) return undefined;
|
|
1693
|
-
const ms = Date.parse(v);
|
|
1694
|
-
return Number.isFinite(ms) ? ms : undefined;
|
|
1695
|
-
}
|
|
1696
|
-
|
|
1697
1722
|
function nodeRowToFileNode(row: Record<string, unknown>): FileNode | null {
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
typeof row.storage === 'string' && row.storage
|
|
1703
|
-
? row.storage
|
|
1704
|
-
: configured.length === 1
|
|
1705
|
-
? configured[0].name
|
|
1706
|
-
: '';
|
|
1707
|
-
if (multiStorageRoot.value && !storageName) return null;
|
|
1708
|
-
const name = String(row.name ?? rel.split('/').pop() ?? '');
|
|
1709
|
-
const isDir = row.type === 'dir';
|
|
1710
|
-
const size = typeof row.size === 'number' ? row.size : 0;
|
|
1711
|
-
const id = typeof row.id === 'number' ? row.id : undefined;
|
|
1712
|
-
return {
|
|
1713
|
-
type: isDir ? 'dir' : 'file',
|
|
1714
|
-
id,
|
|
1715
|
-
/* tablo:t1 — ⚠⚠ THE DATE. A node row carries `backend_mtime` (what the
|
|
1716
|
-
storage says) and `db_mtime` (what our last scan recorded) as RFC3339
|
|
1717
|
-
strings; `FileNode.last_modified` is unix MILLISECONDS. Nothing mapped
|
|
1718
|
-
between the two, so every row from Recent, Starred and a tag view
|
|
1719
|
-
arrived with no date at all — measured on Recent: eleven rows, eleven em
|
|
1720
|
-
dashes in the Modified column, and a Modified column header you could
|
|
1721
|
-
click that then sorted nothing. It also made the date grouping this view
|
|
1722
|
-
is supposed to show impossible, because every row fell in the "No date"
|
|
1723
|
-
bucket. Storage first: `backend_mtime` is the file's own truth and
|
|
1724
|
-
`db_mtime` only says when we last looked at it. */
|
|
1725
|
-
last_modified: rowMillis(row.backend_mtime) ?? rowMillis(row.db_mtime),
|
|
1726
|
-
path: storageName ? `${storageName}://${rel}` : rel,
|
|
1727
|
-
basename: name,
|
|
1728
|
-
extension: isDir
|
|
1729
|
-
? ''
|
|
1730
|
-
: name.includes('.')
|
|
1731
|
-
? (name.split('.').pop() || '').toLowerCase()
|
|
1732
|
-
: '',
|
|
1733
|
-
storage: storageName,
|
|
1734
|
-
visibility: 'private',
|
|
1735
|
-
size,
|
|
1736
|
-
file_size: size,
|
|
1737
|
-
mime_type: typeof row.mime === 'string' ? row.mime : '',
|
|
1738
|
-
// Keyed by node id. A file with no rendered thumbnail 404s here and the
|
|
1739
|
-
// view falls back to its icon — the contract the ordinary listing has too.
|
|
1740
|
-
thumb_url: !isDir && id !== undefined ? `/api/files/thumb/${id}` : undefined,
|
|
1741
|
-
extra_metadata: {},
|
|
1742
|
-
} as unknown as FileNode;
|
|
1723
|
+
return nodeRowToFileNodePure(row, {
|
|
1724
|
+
storages: props.config.storages ?? [],
|
|
1725
|
+
multiStorageRoot: multiStorageRoot.value,
|
|
1726
|
+
});
|
|
1743
1727
|
}
|
|
1744
1728
|
|
|
1745
1729
|
/** GET one of the view endpoints. Returns rows already in listing shape. */
|
|
@@ -1829,6 +1813,7 @@ async function loadNavView(kind: Exclude<NavView, ''>) {
|
|
|
1829
1813
|
navTag.value = '';
|
|
1830
1814
|
trashMode.value = false;
|
|
1831
1815
|
e2eRoot.value = '';
|
|
1816
|
+
forgetFolderPerm();
|
|
1832
1817
|
selection.clear();
|
|
1833
1818
|
files.value = [];
|
|
1834
1819
|
dirname.value = NAV_VIEW_DIRNAME.home;
|
|
@@ -1862,6 +1847,7 @@ async function loadNavView(kind: Exclude<NavView, ''>) {
|
|
|
1862
1847
|
navTag.value = '';
|
|
1863
1848
|
trashMode.value = false;
|
|
1864
1849
|
e2eRoot.value = '';
|
|
1850
|
+
forgetFolderPerm();
|
|
1865
1851
|
selection.clear();
|
|
1866
1852
|
try {
|
|
1867
1853
|
files.value = await fetchNavRows(kind);
|
|
@@ -1902,6 +1888,7 @@ async function loadTagView(tag: string) {
|
|
|
1902
1888
|
navTag.value = name;
|
|
1903
1889
|
trashMode.value = false;
|
|
1904
1890
|
e2eRoot.value = '';
|
|
1891
|
+
forgetFolderPerm();
|
|
1905
1892
|
selection.clear();
|
|
1906
1893
|
try {
|
|
1907
1894
|
const rows = await fetchTaggedRows(name, {
|
|
@@ -2034,6 +2021,14 @@ function onInspectorManage(n: FileNode) {
|
|
|
2034
2021
|
// (the pre-RBAC default). Otherwise 'editor'/'owner' may write; only 'owner'
|
|
2035
2022
|
// manages permissions. Enforcement is server-side; this just shapes the menu.
|
|
2036
2023
|
function permCanEdit(p: string | undefined): boolean {
|
|
2024
|
+
// A read-only storage refuses every write on the server (403 "storage is
|
|
2025
|
+
// read-only") whatever level the ACL grants — an owner of a read-only mount
|
|
2026
|
+
// is an owner who cannot write. Say so here rather than after the click:
|
|
2027
|
+
// until this line the toolbar offered New folder / Upload and the sidebar's
|
|
2028
|
+
// "+ New" menu on a read-only mount, and the user found out from the error
|
|
2029
|
+
// (issue #30). The level itself is left alone: 'owner' still opens the
|
|
2030
|
+
// permissions panel, sharing a read-only file is still allowed.
|
|
2031
|
+
if (dirReadOnly.value) return false;
|
|
2037
2032
|
// undefined = ACL not enforced (dev / unwired) → full access. In production
|
|
2038
2033
|
// the backend always sends a level; 'none'/'viewer' cannot write, only
|
|
2039
2034
|
// 'editor'/'owner' can.
|
|
@@ -2042,11 +2037,83 @@ function permCanEdit(p: string | undefined): boolean {
|
|
|
2042
2037
|
function permIsOwner(p: string | undefined): boolean {
|
|
2043
2038
|
return p === 'owner';
|
|
2044
2039
|
}
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2040
|
+
/* ⚠⚠ THE CONTEXT MENU MUST BE THE SAME EVERYWHERE (owner, 2026-09-19: "son
|
|
2041
|
+
* kullanılanlar, ana sayfa gibi sayfalarda context menu eksik kalıyor.
|
|
2042
|
+
* CONTEXT MENÜ HER YERDE AYNI OLMALI").
|
|
2043
|
+
*
|
|
2044
|
+
* `dirPerm` / `dirReadOnly` describe the FOLDER being listed, and only
|
|
2045
|
+
* `load()` sets them. Recent, Starred, Shared, a tag view and Home list rows
|
|
2046
|
+
* from every storage and have no folder — so whatever folder was open LAST
|
|
2047
|
+
* answered for them. On the landing page that is no folder at all
|
|
2048
|
+
* (`dirPerm === ''`), and `permCanEdit('')` is false, so the menu on a Home
|
|
2049
|
+
* card or a Recent row came up without Rename / Delete / Move to / Share.
|
|
2050
|
+
* After a writable folder had been visited the stale level said yes to
|
|
2051
|
+
* everything, including rows on a read-only mount. Intermittent, therefore
|
|
2052
|
+
* reported as "eksik kalıyor" rather than "yok".
|
|
2053
|
+
*
|
|
2054
|
+
* Three rules restore one menu:
|
|
2055
|
+
* 1. entering a virtual view forgets the folder (`forgetFolderPerm`), so
|
|
2056
|
+
* nothing stale can leak into it;
|
|
2057
|
+
* 2. a row answers for itself — its own `perm` (every listing carries it
|
|
2058
|
+
* now, `handlers/meta.go`) and its own storage's `read_only`;
|
|
2059
|
+
* 3. a multi-selection is as weak as its weakest row, because the server
|
|
2060
|
+
* refuses the whole batch on the one row the caller may not touch.
|
|
2061
|
+
* What stays hidden in a virtual view is only what is structurally
|
|
2062
|
+
* impossible there — New folder, Upload, Paste need a destination folder
|
|
2063
|
+
* (`atVirtualRoot`).
|
|
2064
|
+
*/
|
|
2065
|
+
function forgetFolderPerm() {
|
|
2066
|
+
dirPerm.value = '';
|
|
2067
|
+
dirReadOnly.value = false;
|
|
2068
|
+
}
|
|
2069
|
+
/** A view whose rows span every storage: no folder is behind it. */
|
|
2070
|
+
const inVirtualView = computed(() => !!navView.value && navView.value !== 'trash');
|
|
2071
|
+
/** The row's own level; the folder's when the row has none. In a virtual view
|
|
2072
|
+
* a row without a level (a server older than `handlers/meta.go`'s `perm`)
|
|
2073
|
+
* is ungated — the server enforces, this only shapes the menu — rather than
|
|
2074
|
+
* gated by an empty folder level that would hide every write verb. */
|
|
2075
|
+
function rowPerm(n: FileNode): string | undefined {
|
|
2076
|
+
if (typeof n.perm === 'string') return n.perm;
|
|
2077
|
+
return inVirtualView.value ? undefined : dirPerm.value;
|
|
2078
|
+
}
|
|
2079
|
+
/** The row sits on a read-only storage: its own `read_only` (nav/tag rows),
|
|
2080
|
+
* else the host's storage list — a folder listing's rows carry no flag of
|
|
2081
|
+
* their own because `dirReadOnly` already answers for the whole folder. */
|
|
2082
|
+
function nodeReadOnly(n: FileNode): boolean {
|
|
2083
|
+
if (n.read_only === true) return true;
|
|
2084
|
+
const storage = typeof n.storage === 'string' ? n.storage : '';
|
|
2085
|
+
if (!storage) return false;
|
|
2086
|
+
return (props.config.storages ?? []).some((s) => s.name === storage && s.readOnly === true);
|
|
2087
|
+
}
|
|
2088
|
+
/** May the caller write THIS row — level and read-only mount folded together.
|
|
2089
|
+
* The one predicate behind the editor/preview split and the menu's write
|
|
2090
|
+
* verbs, so a Home card and a listing row cannot disagree. */
|
|
2091
|
+
function nodeCanEdit(n: FileNode): boolean {
|
|
2092
|
+
return permCanEdit(rowPerm(n)) && !nodeReadOnly(n);
|
|
2093
|
+
}
|
|
2094
|
+
const PERM_RANK: Record<string, number> = { none: 0, viewer: 1, editor: 2, owner: 3 };
|
|
2095
|
+
// Effective perm for a selection: a single entry's own perm, the WEAKEST of a
|
|
2096
|
+
// multi-selection's own perms, else the directory perm (background / rows
|
|
2097
|
+
// that carry no level).
|
|
2098
|
+
function selPerm(sel: FileNode[]): string | undefined {
|
|
2099
|
+
if (sel.length === 0) return dirPerm.value;
|
|
2100
|
+
let weakest: string | undefined;
|
|
2101
|
+
let ungated = false;
|
|
2102
|
+
for (const n of sel) {
|
|
2103
|
+
const p = rowPerm(n);
|
|
2104
|
+
if (p === undefined) {
|
|
2105
|
+
ungated = true;
|
|
2106
|
+
continue;
|
|
2107
|
+
}
|
|
2108
|
+
if (weakest === undefined || (PERM_RANK[p] ?? 0) < (PERM_RANK[weakest] ?? 0)) weakest = p;
|
|
2109
|
+
}
|
|
2110
|
+
// An ungated row (no level, unwired ACL) never strengthens a selection
|
|
2111
|
+
// that also holds a gated one; alone, it stays ungated.
|
|
2112
|
+
return weakest ?? (ungated ? undefined : dirPerm.value);
|
|
2113
|
+
}
|
|
2114
|
+
/** Any row of the selection on a read-only storage → the selection is. */
|
|
2115
|
+
function selReadOnly(sel: FileNode[]): boolean {
|
|
2116
|
+
return sel.some(nodeReadOnly);
|
|
2050
2117
|
}
|
|
2051
2118
|
// Can the current user write into the directory being viewed? Gates the
|
|
2052
2119
|
// toolbar New Folder / Upload / Paste + drag-drop upload.
|
|
@@ -2350,6 +2417,7 @@ async function load(path?: string) {
|
|
|
2350
2417
|
adapter.value = resp.adapter;
|
|
2351
2418
|
dirname.value = resp.dirname;
|
|
2352
2419
|
dirPerm.value = (resp.perm as string) || '';
|
|
2420
|
+
dirReadOnly.value = resp.read_only === true;
|
|
2353
2421
|
/* wiring:e2 — backend tells us when this dir sits inside an encrypted
|
|
2354
2422
|
subtree; '' resets on every plain folder. Drives the lock screen. */
|
|
2355
2423
|
e2eRoot.value = typeof resp.e2e_root === 'string' ? resp.e2e_root : '';
|
|
@@ -3121,7 +3189,7 @@ function openNode(n: FileNode) {
|
|
|
3121
3189
|
const ext = (n.extension || '').toLowerCase();
|
|
3122
3190
|
// RBAC: viewers (no edit on this item) always get the read-only preview
|
|
3123
3191
|
// modal — never the editable surface. This is the "view vs edit" split.
|
|
3124
|
-
previewMode.value =
|
|
3192
|
+
previewMode.value = nodeCanEdit(n)
|
|
3125
3193
|
? previewModeForExt(ext)
|
|
3126
3194
|
: 'view';
|
|
3127
3195
|
previewTarget.value = n;
|
|
@@ -3223,7 +3291,7 @@ function openNodeInNewTab(n: FileNode) {
|
|
|
3223
3291
|
}
|
|
3224
3292
|
// RBAC: a viewer (no edit on this item) can't use the editable "Aç"
|
|
3225
3293
|
// surface — drop to the read-only in-page preview instead.
|
|
3226
|
-
if (!
|
|
3294
|
+
if (!nodeCanEdit(n)) {
|
|
3227
3295
|
previewNode(n);
|
|
3228
3296
|
return;
|
|
3229
3297
|
}
|
|
@@ -3650,7 +3718,10 @@ function selectionActionList(sel: FileNode[]): ContextAction[] {
|
|
|
3650
3718
|
// RBAC: gate mutating actions when the caller lacks edit on the target. The
|
|
3651
3719
|
// "İzinler" (permissions) action shows only for owners on RBAC-on storages.
|
|
3652
3720
|
const p = selPerm(sel);
|
|
3653
|
-
|
|
3721
|
+
// May write to the selection: the weakest row's level, and no row on a
|
|
3722
|
+
// read-only mount — `selReadOnly` is what `dirReadOnly` cannot answer in a
|
|
3723
|
+
// view whose rows come from several storages (Recent, Starred, a tag, Home).
|
|
3724
|
+
const w = permCanEdit(p) && !selReadOnly(sel);
|
|
3654
3725
|
// Unified "Paylaş / İzinler" popup: public share link (editor+) + per-user
|
|
3655
3726
|
// permissions (owner-only, decided inside the modal).
|
|
3656
3727
|
// Unified "Paylaş / İzinler" popup carries the public share link, per-user
|
|
@@ -3703,7 +3774,10 @@ function selectionActionList(sel: FileNode[]): ContextAction[] {
|
|
|
3703
3774
|
They borrow the clipboard verbs' marks, which is what they are. */
|
|
3704
3775
|
{ key: 'move-to', label: t('ctx.move_to'), icon: 'cut', hidden: !any || !w, disabled: !any },
|
|
3705
3776
|
{ key: 'copy-to', label: t('ctx.copy_to'), icon: 'copy', hidden: !any, disabled: !any },
|
|
3706
|
-
|
|
3777
|
+
/* Paste needs a destination folder; a virtual view (Recent / Starred /
|
|
3778
|
+
Shared / a tag / Home) has none, so it is the one write verb the row
|
|
3779
|
+
menu does not carry there — every per-row verb above and below does. */
|
|
3780
|
+
{ key: 'paste', label: t('ctx.paste'), hidden: !w || atVirtualRoot.value, disabled: !clipboard.value.mode },
|
|
3707
3781
|
{ divider: true, key: 'sep-meta', label: '', hidden: !singleHasId && !canStar },
|
|
3708
3782
|
/* yildiz:s1 — "star must be an action, like a tag" (owner, v0.30.0).
|
|
3709
3783
|
Beside Tags on purpose: they are the same kind of verb, and this is the
|
|
@@ -4108,7 +4182,7 @@ async function onDocumentCreated(file: { path: string; name: string; ext: string
|
|
|
4108
4182
|
previewTarget.value = node;
|
|
4109
4183
|
// ⚠ NOT previewModeForExt: that sends office types to 'view', which is right
|
|
4110
4184
|
// for a peek at somebody else's file and wrong for the one you just made.
|
|
4111
|
-
previewMode.value =
|
|
4185
|
+
previewMode.value = nodeCanEdit(node) ? 'edit' : 'view';
|
|
4112
4186
|
showPreview.value = true;
|
|
4113
4187
|
emit('file-opened', { path: node.path, basename: node.basename });
|
|
4114
4188
|
void markRecent(node);
|
|
@@ -7281,6 +7355,7 @@ function closeRecoveryKey() {
|
|
|
7281
7355
|
:limit="20"
|
|
7282
7356
|
:refresh-key="recentRefreshKey"
|
|
7283
7357
|
@open="onRecentOpen"
|
|
7358
|
+
@context="onRecentContext"
|
|
7284
7359
|
@error="(msg: string) => emit('error', { message: msg, context: { op: 'recents' } })"
|
|
7285
7360
|
/>
|
|
7286
7361
|
</div>
|
|
@@ -144,9 +144,12 @@ const storageTile = fileIconTile({ type: 'dir', mime_type: 'inode/storage' });
|
|
|
144
144
|
* with the wrong size line says something false.
|
|
145
145
|
*/
|
|
146
146
|
function storageCaption(s: HomeStorage): string {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
147
|
+
const base =
|
|
148
|
+
typeof s.usedBytes === 'number' && s.usedBytes >= 0
|
|
149
|
+
? t('drive.storage.used_unlimited', { used: formatSize(s.usedBytes) })
|
|
150
|
+
: t('conn.guide.storage');
|
|
151
|
+
// A read-only mount says so on its card too (issue #30).
|
|
152
|
+
return s.readOnly ? `${base} · ${t('sidenav.storage.readOnly')}` : base;
|
|
150
153
|
}
|
|
151
154
|
|
|
152
155
|
/**
|
|
@@ -13,6 +13,10 @@ interface RecentNode {
|
|
|
13
13
|
name: string;
|
|
14
14
|
mime?: string;
|
|
15
15
|
last_opened?: string;
|
|
16
|
+
/** The raw row carries more (`storage`, `perm`, `read_only`, `type`,
|
|
17
|
+
* the mtimes…) — the explorer turns it into a FileNode for the context
|
|
18
|
+
* menu with the same converter Recent / Starred / a tag view use. */
|
|
19
|
+
[k: string]: unknown;
|
|
16
20
|
}
|
|
17
21
|
|
|
18
22
|
const props = defineProps<{
|
|
@@ -30,6 +34,11 @@ const props = defineProps<{
|
|
|
30
34
|
|
|
31
35
|
const emit = defineEmits<{
|
|
32
36
|
(e: 'open', node: RecentNode): void;
|
|
37
|
+
/** Right-click on a row. The tray does not build a menu of its own — the
|
|
38
|
+
* explorer opens the ONE menu every listing row gets (owner: "CONTEXT
|
|
39
|
+
* MENÜ HER YERDE AYNI OLMALI"), so a file here offers exactly what the
|
|
40
|
+
* same file offers in its folder. */
|
|
41
|
+
(e: 'context', node: RecentNode, ev: MouseEvent): void;
|
|
33
42
|
(e: 'error', message: string): void;
|
|
34
43
|
}>();
|
|
35
44
|
|
|
@@ -101,8 +110,13 @@ watch(() => props.refreshKey, load);
|
|
|
101
110
|
</header>
|
|
102
111
|
|
|
103
112
|
<ul v-if="items.length">
|
|
104
|
-
<li v-for="n in items" :key="n.id">
|
|
105
|
-
<button
|
|
113
|
+
<li v-for="n in items" :key="n.id" data-testid="recent-tray-item" :data-fe-path="n.path">
|
|
114
|
+
<button
|
|
115
|
+
type="button"
|
|
116
|
+
class="filex-recent-item"
|
|
117
|
+
@click="emit('open', n)"
|
|
118
|
+
@contextmenu.prevent="emit('context', n, $event)"
|
|
119
|
+
>
|
|
106
120
|
<span class="filex-recent-name">{{ n.name }}</span>
|
|
107
121
|
<span class="filex-recent-meta">{{ fmtTime(n.last_opened) }}</span>
|
|
108
122
|
</button>
|
|
@@ -22,6 +22,7 @@ import type { ExplorerConfig, LocaleCode } from '../types/ExplorerConfig';
|
|
|
22
22
|
import type { S3AccessKey } from '../types/S3Keys';
|
|
23
23
|
import { useLocale } from '../composables/useLocale';
|
|
24
24
|
import { useS3Keys } from '../composables/useS3Keys';
|
|
25
|
+
import { useScrolledX } from '../composables/useScrolledX';
|
|
25
26
|
import { resolveLocale } from '../locales/resolve';
|
|
26
27
|
|
|
27
28
|
const props = defineProps<{
|
|
@@ -37,6 +38,7 @@ const emit = defineEmits<{
|
|
|
37
38
|
|
|
38
39
|
const locale = computed<LocaleCode>(() => resolveLocale(props.config.locale));
|
|
39
40
|
const { t, formatDate } = useLocale(locale);
|
|
41
|
+
const { scrolledX, onScroll } = useScrolledX();
|
|
40
42
|
|
|
41
43
|
const {
|
|
42
44
|
keys,
|
|
@@ -231,33 +233,35 @@ function scopeOf(k: S3AccessKey): string {
|
|
|
231
233
|
|
|
232
234
|
<!-- ── the keys ─────────────────────────────────────────────── -->
|
|
233
235
|
<p v-if="loading" class="fe-s3keys__muted">…</p>
|
|
234
|
-
<
|
|
235
|
-
<
|
|
236
|
-
<
|
|
237
|
-
<
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
<
|
|
246
|
-
<
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
<
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
236
|
+
<div v-else-if="keys.length" class="fe-s3keys__scroll" :class="{ 'is-scrolled-x': scrolledX }" @scroll.passive="onScroll">
|
|
237
|
+
<table class="fe-s3keys__table">
|
|
238
|
+
<thead>
|
|
239
|
+
<tr>
|
|
240
|
+
<th>{{ t('conn.s3keys.col.label') }}</th>
|
|
241
|
+
<th>{{ t('conn.s3keys.col.key') }}</th>
|
|
242
|
+
<th>{{ t('conn.s3keys.col.scope') }}</th>
|
|
243
|
+
<th>{{ t('conn.s3keys.col.lastUsed') }}</th>
|
|
244
|
+
<th></th>
|
|
245
|
+
</tr>
|
|
246
|
+
</thead>
|
|
247
|
+
<tbody>
|
|
248
|
+
<tr v-for="k in keys" :key="k.id" :class="{ 'is-off': !!k.disabled_at }">
|
|
249
|
+
<td>{{ k.label || t('conn.s3keys.noLabel') }}</td>
|
|
250
|
+
<td><code>{{ k.access_key_id }}</code></td>
|
|
251
|
+
<td>{{ scopeOf(k) }}</td>
|
|
252
|
+
<td>{{ k.last_used_at ? shortDate(k.last_used_at) : t('conn.s3keys.neverUsed') }}</td>
|
|
253
|
+
<td class="fe-s3keys__actions">
|
|
254
|
+
<button class="fe-s3keys__link" :disabled="busy" @click="toggle(k)">
|
|
255
|
+
{{ k.disabled_at ? t('conn.s3keys.enable') : t('conn.s3keys.disable') }}
|
|
256
|
+
</button>
|
|
257
|
+
<button class="fe-s3keys__link is-danger" :disabled="busy" @click="revoke(k)">
|
|
258
|
+
{{ confirmRevoke === k.id ? t('conn.s3keys.confirm') : t('conn.s3keys.revoke') }}
|
|
259
|
+
</button>
|
|
260
|
+
</td>
|
|
261
|
+
</tr>
|
|
262
|
+
</tbody>
|
|
263
|
+
</table>
|
|
264
|
+
</div>
|
|
261
265
|
<p v-else-if="canMint" class="fe-s3keys__muted">{{ t('conn.s3keys.empty') }}</p>
|
|
262
266
|
</section>
|
|
263
267
|
</template>
|
|
@@ -389,4 +393,6 @@ function scopeOf(k: S3AccessKey): string {
|
|
|
389
393
|
white-space: nowrap;
|
|
390
394
|
text-align: right;
|
|
391
395
|
}
|
|
396
|
+
/* Its pinning (sticky right, ground, scroll divider) is in styles/base.css
|
|
397
|
+
next to the list view's `.fe-list__col--menu`, so the two stay one model. */
|
|
392
398
|
</style>
|
|
@@ -18,6 +18,7 @@ import type { ExplorerConfig, LocaleCode } from '../types/ExplorerConfig';
|
|
|
18
18
|
import type { FTPSFacts, SSHPublicKey } from '../types/SSHKeys';
|
|
19
19
|
import { useLocale } from '../composables/useLocale';
|
|
20
20
|
import { useSSHKeys } from '../composables/useSSHKeys';
|
|
21
|
+
import { useScrolledX } from '../composables/useScrolledX';
|
|
21
22
|
import { resolveLocale } from '../locales/resolve';
|
|
22
23
|
|
|
23
24
|
const props = defineProps<{
|
|
@@ -50,6 +51,7 @@ const emit = defineEmits<{
|
|
|
50
51
|
|
|
51
52
|
const locale = computed<LocaleCode>(() => resolveLocale(props.config.locale));
|
|
52
53
|
const { t, formatDate } = useLocale(locale);
|
|
54
|
+
const { scrolledX, onScroll } = useScrolledX();
|
|
53
55
|
|
|
54
56
|
const { keys, connection, loading, error, canAdd, hasUsableKey, load, add, setDisabled, remove } =
|
|
55
57
|
useSSHKeys(props.config);
|
|
@@ -171,31 +173,33 @@ function fingerprintOf(k: SSHPublicKey): string {
|
|
|
171
173
|
</div>
|
|
172
174
|
|
|
173
175
|
<p v-if="loading" class="fe-s3keys__muted">…</p>
|
|
174
|
-
<
|
|
175
|
-
<
|
|
176
|
-
<
|
|
177
|
-
<
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
<
|
|
185
|
-
<
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
<
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
176
|
+
<div v-else-if="keys.length" class="fe-s3keys__scroll" :class="{ 'is-scrolled-x': scrolledX }" @scroll.passive="onScroll">
|
|
177
|
+
<table class="fe-s3keys__table">
|
|
178
|
+
<thead>
|
|
179
|
+
<tr>
|
|
180
|
+
<th>{{ t('conn.sshkeys.col.name') }}</th>
|
|
181
|
+
<th>{{ t('conn.sshkeys.col.fingerprint') }}</th>
|
|
182
|
+
<th>{{ t('conn.sshkeys.col.lastUsed') }}</th>
|
|
183
|
+
<th></th>
|
|
184
|
+
</tr>
|
|
185
|
+
</thead>
|
|
186
|
+
<tbody>
|
|
187
|
+
<tr v-for="k in keys" :key="k.id" :class="{ 'is-off': !!k.disabled_at }">
|
|
188
|
+
<td>{{ k.name || t('conn.sshkeys.noName') }}</td>
|
|
189
|
+
<td><code>{{ fingerprintOf(k) }}</code></td>
|
|
190
|
+
<td>{{ k.last_used_at ? shortDate(k.last_used_at) : t('conn.sshkeys.neverUsed') }}</td>
|
|
191
|
+
<td class="fe-s3keys__actions">
|
|
192
|
+
<button class="fe-s3keys__link" :disabled="busy" @click="toggle(k)">
|
|
193
|
+
{{ k.disabled_at ? t('conn.sshkeys.enable') : t('conn.sshkeys.disable') }}
|
|
194
|
+
</button>
|
|
195
|
+
<button class="fe-s3keys__link is-danger" :disabled="busy" @click="drop(k)">
|
|
196
|
+
{{ confirmRemove === k.id ? t('conn.sshkeys.confirm') : t('conn.sshkeys.remove') }}
|
|
197
|
+
</button>
|
|
198
|
+
</td>
|
|
199
|
+
</tr>
|
|
200
|
+
</tbody>
|
|
201
|
+
</table>
|
|
202
|
+
</div>
|
|
199
203
|
<p v-else-if="canAdd" class="fe-s3keys__muted">{{ t('conn.sshkeys.empty') }}</p>
|
|
200
204
|
</section>
|
|
201
205
|
</template>
|