From 9de9b84b3426b1b63d5aec1b7e9e4a504188970c Mon Sep 17 00:00:00 2001 From: NGPixel Date: Sun, 9 Aug 2026 19:51:50 -0400 Subject: [PATCH] feat: various UI improvements --- backend/api/groups.ts | 8 +- backend/api/navigation.ts | 8 +- backend/api/users.ts | 50 ++++++++++ backend/index.ts | 12 +++ backend/locales/en.json | 7 ++ backend/models/users.ts | 38 +++++++- .../public/_assets/icons/color-send-file.svg | 1 + .../src/components/ApiKeyCreateDialog.vue | 13 ++- frontend/src/components/FileManager.vue | 78 +++++++++++---- .../src/components/FolderCreateDialog.vue | 7 +- frontend/src/components/NavEditOverlay.vue | 5 +- frontend/src/components/SiteCreateDialog.vue | 7 +- frontend/src/helpers/fileTypes.js | 5 + frontend/src/pages/AdminApi.vue | 11 +++ frontend/src/pages/AdminDashboard.vue | 95 +++++++++++++++++-- 15 files changed, 308 insertions(+), 37 deletions(-) create mode 100644 frontend/public/_assets/icons/color-send-file.svg diff --git a/backend/api/groups.ts b/backend/api/groups.ts index c74226112..a610bfe19 100644 --- a/backend/api/groups.ts +++ b/backend/api/groups.ts @@ -51,10 +51,16 @@ async function routes(app: FastifyInstance) { '/', { config: { - permissions: ['read:groups', 'manage:groups'] + // -> `manage:navigation` is here because a menu item can be limited to groups, so the + // navigation editor has to be able to name them. It is safe to grant on this route and this + // route only: the listing is `GroupCore`, which carries no permissions, no rules and no + // members — reading one group in full, or its members, keeps needing `manage:groups`. + permissions: ['read:groups', 'manage:groups', 'manage:navigation'] }, schema: { summary: 'List all groups', + description: + 'Every group by id and name, with its member count. Nothing about what a group may do or who is in it — that is `GET /groups/{groupId}`.', tags: ['Groups'], response: { 200: { diff --git a/backend/api/navigation.ts b/backend/api/navigation.ts index 4f96a7ea9..f0c09d9bf 100644 --- a/backend/api/navigation.ts +++ b/backend/api/navigation.ts @@ -20,7 +20,13 @@ const navigationItem = { /** Whether the requester may see and edit a menu whole, rather than only the parts meant for them. */ function canManageNavigation(req: FastifyRequest): boolean { - const permissions = req.session?.authenticated ? (req.session.permissions ?? []) : [] + // -> Same identity resolution as the route permission hook, so a key that may save a menu may also + // read it whole + const permissions = req.apiKey + ? req.apiKey.permissions + : req.session?.authenticated + ? (req.session.permissions ?? []) + : [] return permissions.includes('manage:navigation') || permissions.includes('manage:system') } diff --git a/backend/api/users.ts b/backend/api/users.ts index 556ebf8ce..2c372896b 100644 --- a/backend/api/users.ts +++ b/backend/api/users.ts @@ -162,6 +162,56 @@ async function routes(app: FastifyInstance) { } ) + /** + * RECENT LOGINS + */ + app.get<{ Querystring: { limit?: number } }>( + '/recent-logins', + { + config: { + // -> `access:admin`, not `read:users`: this answers a panel on the admin dashboard, which + // everyone who can open the admin area sees, and it is the same permission `system/info` + // fills the rest of that dashboard with. It is why the answer is identity plus a timestamp + // and nothing else -- the user list, and every account flag on it, still needs `read:users`. + permissions: ['access:admin'] + }, + schema: { + summary: 'List the most recent logins', + description: + 'Who signed in last, most recent first. Accounts that have never logged in are left out rather than trailing the list, as are system accounts — nothing signs in as the guest.', + tags: ['Users'], + querystring: { + type: 'object', + properties: { + limit: { type: 'integer', minimum: 1, maximum: 50, default: 10 } + } + }, + response: { + 200: { + description: 'The most recent logins, newest first', + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'string', format: 'uuid' }, + name: { type: 'string' }, + email: { type: 'string' }, + lastLoginAt: { + type: 'string', + format: 'date-time', + description: 'RFC 3339 Date Time' + } + } + } + } + } + } + }, + async (req) => { + return WIKI.models.users.getRecentLogins({ limit: req.query.limit ?? 10 }) + } + ) + app.get( '/whoami', { diff --git a/backend/index.ts b/backend/index.ts index 71e65548d..b02db4f89 100644 --- a/backend/index.ts +++ b/backend/index.ts @@ -456,6 +456,18 @@ async function initHTTPServer() { }) app.register(fastifySwaggerUi, { routePrefix: '/_api', + /* + Swagger UI's own sorters, applied in the browser: tags down the page, and the operations inside + each tag by path. Neither is on by default — the order is otherwise the order the routes were + registered in, which is meaningful to `api/index.ts` and arbitrary to anyone reading the docs. + + `operationsSorter: 'alpha'` sorts on the path, not the summary, so the several methods of one + path stay together and keep their registration order relative to each other. + */ + uiConfig: { + tagsSorter: 'alpha', + operationsSorter: 'alpha' + }, // -> Left empty so the plugin inlines neither its own logo nor one of ours; the stylesheet below // is what puts the site's logo in the topbar logo: {} as any, diff --git a/backend/locales/en.json b/backend/locales/en.json index 92e43fbdb..4e4a87a38 100644 --- a/backend/locales/en.json +++ b/backend/locales/en.json @@ -15,6 +15,7 @@ "admin.api.createdOn": "Created on {date}", "admin.api.disableButton": "Disable API", "admin.api.disabled": "API Disabled", + "admin.api.docsButton": "API Docs", "admin.api.enableButton": "Enable API", "admin.api.enabled": "API Enabled", "admin.api.expiration180d": "180 days", @@ -199,6 +200,7 @@ "admin.dashboard.contributeSubtitle": "Wiki.js is a free and open source project. There are several ways you can contribute to the project.", "admin.dashboard.groups": "Groups", "admin.dashboard.lastLogins": "Last Logins", + "admin.dashboard.lastLoginsNone": "No logins recorded yet.", "admin.dashboard.mostPopularPages": "Most Popular Pages", "admin.dashboard.pages": "Pages", "admin.dashboard.recentPages": "Recent Pages", @@ -782,6 +784,7 @@ "admin.security.warn": "Make sure to understand the implications before turning on / off a security feature.", "admin.sites.activate": "Activate Site", "admin.sites.activateConfirm": "Are you sure you want activate site {siteTitle}? The site will become accessible to users with read access.", + "admin.sites.createInvalidData": "Some fields are missing or have invalid data.", "admin.sites.createSuccess": "Site created successfully.", "admin.sites.deactivate": "Deactivate Site", "admin.sites.deactivateConfirm": "Are you sure you want deactivate site {siteTitle}? The site will no longer be accessible to users.", @@ -792,7 +795,11 @@ "admin.sites.edit": "Edit Site", "admin.sites.hostname": "Hostname", "admin.sites.hostnameHint": "Must be a fully-qualified domain name (e.g. wiki.example.com) or * for a catch-all site. Note that there can only be 1 catch-all site.", + "admin.sites.hostnameInvalidChars": "Hostname has invalid characters.", + "admin.sites.hostnameMissing": "Hostname is missing.", "admin.sites.isActive": "Active", + "admin.sites.nameInvalidChars": "Site name has invalid characters.", + "admin.sites.nameMissing": "Site name is missing.", "admin.sites.new": "New Site", "admin.sites.refreshSuccess": "List of sites refreshed successfully.", "admin.sites.subtitle": "Manage your wiki sites", diff --git a/backend/models/users.ts b/backend/models/users.ts index 2a877bd2f..1faf22635 100644 --- a/backend/models/users.ts +++ b/backend/models/users.ts @@ -9,7 +9,7 @@ import { users as usersTable, userKeys } from '../db/schema.ts' -import { and, count, eq, ilike, inArray, notExists, or, sql } from 'drizzle-orm' +import { and, count, desc, eq, ilike, inArray, isNotNull, notExists, or, sql } from 'drizzle-orm' import { nanoid } from 'nanoid' import { flatten, uniq } from 'es-toolkit/array' import { detectImageMime, resizeImageToSquareJpeg } from '../helpers/images.ts' @@ -37,6 +37,14 @@ export interface UserPage { users: UserCore[] } +/** A user and when they last signed in — all `getRecentLogins()` discloses. */ +export interface RecentLogin { + id: string + name: string + email: string + lastLoginAt: Date | null +} + /** * An authentication provider linked to a user, as exposed by the API. Secrets held in the stored * `auth` blob (the password hash, the TFA secret) are never included — `isPasswordSet` and @@ -235,6 +243,34 @@ class Users { return res?.[0] ?? null } + /** + * Fetch the users who logged in most recently, most recent first. + * + * Identity and the moment only — this answers a dashboard panel readable by anyone in the admin area, + * which is a tier below the `read:users` that the user list itself needs, so it deliberately carries + * none of the account state `getUsers()` selects. + * + * An account that has never logged in has no place in the answer rather than trailing the end of it, + * hence the `isNotNull`. System accounts are excluded because the guest is one: nothing signs in as + * it, and a `lastLoginAt` on it would be an artefact rather than a visit. + * + * @param limit How many to return + * @returns The most recent logins, newest first + */ + async getRecentLogins({ limit = 10 }: { limit?: number } = {}): Promise { + return WIKI.db + .select({ + id: usersTable.id, + name: usersTable.name, + email: usersTable.email, + lastLoginAt: usersTable.lastLoginAt + }) + .from(usersTable) + .where(and(isNotNull(usersTable.lastLoginAt), eq(usersTable.isSystem, false))) + .orderBy(desc(usersTable.lastLoginAt)) + .limit(limit) + } + /** * Fetch a page of users, optionally filtered by name or email * diff --git a/frontend/public/_assets/icons/color-send-file.svg b/frontend/public/_assets/icons/color-send-file.svg new file mode 100644 index 000000000..63d0db0b8 --- /dev/null +++ b/frontend/public/_assets/icons/color-send-file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/components/ApiKeyCreateDialog.vue b/frontend/src/components/ApiKeyCreateDialog.vue index 695f59c5b..1f1155d19 100644 --- a/frontend/src/components/ApiKeyCreateDialog.vue +++ b/frontend/src/components/ApiKeyCreateDialog.vue @@ -5,9 +5,16 @@ {{ t(`admin.api.newKeyTitle`) }} + - + - + + - - {{ - t(`common.actions.close`) - }} + rounded + color="white" + :aria-label="t(`common.actions.viewDocs`)" + icon="la:question-circle" + :href="siteStore.docsBase + `/editor/file-manager`" + target="_blank"> + + {{ t(`common.actions.viewDocs`) }} + + + @@ -615,7 +631,8 @@ const files = computed(() => { break } case 'page': { - f.icon = fileTypes.page.icon + // -> A redirection has a target where a page has content, so it reads as its own kind of row + f.icon = f.pageType === 'redirect' ? fileTypes.redirect.icon : fileTypes.page.icon f.caption = t(`fileman.${f.pageType}PageType`) break } @@ -1360,10 +1377,29 @@ onBeforeUnmount(() => {