feat: various UI improvements

scarlett
NGPixel 1 month ago
parent 8e6a35de98
commit 9de9b84b34
No known key found for this signature in database

@ -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: {

@ -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')
}

@ -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',
{

@ -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,

@ -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",

@ -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<RecentLogin[]> {
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
*

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="96px" height="96px"><path fill="#90CAF9" d="M40 45L8 45 8 3 30 3 40 13z"/><path fill="#E1F5FE" d="M38.5 14L29 14 29 4.5z"/><path fill="#1565C0" d="M35 28.5L26 36 26 21z"/><path fill="#1565C0" d="M13 26H30V31H13z"/></svg>

After

Width:  |  Height:  |  Size: 288 B

@ -5,9 +5,16 @@
<w-icon name="img:/_assets/icons/fluent-plus-plus.svg" size="sm" class="mr-2" />
<span>{{ t(`admin.api.newKeyTitle`) }}</span>
</w-card-section>
<!--
No `self-start` on the icons. Top-aligning one is for a row whose main section is TALLER than the
field it holds -- a field showing a hint line underneath, or a stack of several controls -- where
the icon belongs against the first of them. Every field here passes `hide-bottom-space`, which
suppresses that hint line, so each row is the field alone and `self-start` lifted the icon 8px
above the field it labels. Centred is what lines the two up, as in `UserCreateDialog`.
-->
<w-form ref="createKeyForm" class="py-2" @submit="create">
<w-item>
<blueprint-icon icon="grand-master-key" class="self-start" />
<blueprint-icon icon="grand-master-key" />
<w-item-section>
<w-input
ref="iptName"
@ -23,7 +30,7 @@
</w-item-section>
</w-item>
<w-item>
<blueprint-icon icon="schedule" class="self-start" />
<blueprint-icon icon="schedule" />
<w-item-section>
<!--
Single-select: a key has one lifetime. It was declared `multiple` against a string
@ -45,7 +52,7 @@
</w-item-section>
</w-item>
<w-item>
<blueprint-icon icon="access" class="self-start" />
<blueprint-icon icon="access" />
<w-item-section>
<w-select
v-model="state.keyGroups"

@ -6,8 +6,11 @@
<span>{{ t(`fileman.title`) }}</span>
</w-toolbar>
<w-toolbar dark>
<!-- -> Same gate the sidebar's locale button uses in `MainLayout`: with the site's locale menu
off, switching locale is not something a reader is offered anywhere -->
<w-btn
class="mr-2 acrylic-btn"
v-if="siteStore.locales.showMenu"
class="fileman-locale mr-2 acrylic-btn"
flat
color="white"
:label="commonStore.locale"
@ -46,25 +49,38 @@
</button>
</div>
</w-toolbar>
<!--
The same chrome the editing overlays close themselves with -- see `NavEditOverlay`: a flat round
help button, then the pushed group. One button in the group here, since there is nothing to save;
`push` goes on the buttons, which is where `WBtn` reads it, not on the group.
-> No right margin on the last control: the toolbar's own 12px is already close to the 9-10px the
header leaves above and below.
-->
<w-toolbar dark>
<w-space />
<!--
-> No right margin needed: the toolbar's own 12px is already close to the 9-10px the header
leaves above and below. What made the button look pushed into the corner was the broken
search field inflating the header to 61px, which stretched those two gaps to 14/15.
-->
<w-btn
class="mr-2"
flat
dense
no-caps
color="red-3"
:aria-label="t(`common.actions.close`)"
icon="la:times"
@click="close">
<w-tooltip anchor="bottom middle" self="top middle">{{
t(`common.actions.close`)
}}</w-tooltip>
rounded
color="white"
:aria-label="t(`common.actions.viewDocs`)"
icon="la:question-circle"
:href="siteStore.docsBase + `/editor/file-manager`"
target="_blank">
<!-- -> `WTooltip` already defaults to below-the-trigger, which is where a header wants it -->
<w-tooltip>{{ t(`common.actions.viewDocs`) }}</w-tooltip>
</w-btn>
<w-btn-group>
<w-btn
push
color="white"
text-color="grey-7"
:label="t(`common.actions.close`)"
:aria-label="t(`common.actions.close`)"
icon="la:times"
@click="close" />
</w-btn-group>
</w-toolbar>
</w-header>
<w-drawer class="fileman-left" :model-value="true" :width="350">
@ -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(() => {
<style lang="scss">
.fileman {
/*
The search pill, mirroring `.header-search-field` in HeaderSearch: 40px tall, dark fill on the
The locale button is cut to the same 7px as the search field and Close, where `WBtn`'s flat variant
is 3px. Unlayered, because an SFC style block is not a Tailwind layer -- which is what lets it beat
the `rounded-[3px]` utility the component carries, the same way `.w-btn.acrylic-btn` in `_base.scss`
beats its hover utility. Specificity alone would not do it: both selectors are one class.
*/
&-locale {
border-radius: 7px;
}
/*
The search field, following `.header-search-field` in HeaderSearch: 40px tall, dark fill on the
dark header, inverting to white ink-on-white in use. Stated here rather than borrowing that
component's class, so a change to the site header cannot silently restyle this overlay -- but the
metrics are deliberately the same, because it is the same control in a different place.
component's class, so a change to the site header cannot silently restyle this overlay -- and the
two have since parted company on both of the things that tie a control to its surroundings.
The FILL: HeaderSearch sits on the site header, which is black, so its neutral `#212121` reads as
a lift out of it. This header is `.card-header` -- `$dark-3` graded towards `$dark-5`, all of them
blue-tinted -- and a neutral grey on a blue-grey ground reads as a different, muddier colour
rather than a raised surface. One step up the same ramp, `$dark-2`, is the lift without the clash.
The CORNERS: 7px, which is `WBtn`'s `push` radius, so the field and the Close button at the other
end of the header are cut to the same shape. A full pill next to a 7px button read as two
unrelated controls that happened to share a row.
*/
&-search {
display: flex;
@ -1373,8 +1409,8 @@ onBeforeUnmount(() => {
gap: 8px;
height: 40px;
padding: 0 8px 0 12px;
border-radius: 9999px;
background-color: #212121;
border-radius: 7px;
background-color: $dark-2;
color: rgba(255, 255, 255, 0.85);
transition:
background-color 0.25s var(--ease-standard),

@ -5,6 +5,11 @@
<w-icon name="img:/_assets/icons/fluent-plus-plus.svg" size="sm" class="mr-2" />
<span>{{ t(`fileman.folderCreate`) }}</span>
</w-card-section>
<!--
Neither icon is `self-start`: both fields pass `hide-bottom-space`, which suppresses the hint
line, so each row is the field alone and a centred icon is what lines up with it. See the note in
`ApiKeyCreateDialog`.
-->
<w-form ref="newFolderForm" class="py-2" @submit="create">
<w-item>
<blueprint-icon icon="folder" />
@ -23,7 +28,7 @@
</w-item-section>
</w-item>
<w-item>
<blueprint-icon icon="file-submodule" class="self-start" />
<blueprint-icon icon="file-submodule" />
<w-item-section>
<w-input
v-model="state.path"

@ -15,8 +15,9 @@
:aria-label="t(`common.actions.viewDocs`)"
icon="la:question-circle"
:href="siteStore.docsBase + `/admin/editors/markdown`"
target="_blank"
type="a" />
target="_blank">
<w-tooltip>{{ t(`common.actions.viewDocs`) }}</w-tooltip>
</w-btn>
<w-btn-group push>
<w-btn
push

@ -5,6 +5,11 @@
<w-icon name="img:/_assets/icons/fluent-plus-plus.svg" size="sm" class="mr-2" />
<span>{{ t(`admin.sites.new`) }}</span>
</w-card-section>
<!--
Neither icon is `self-start`: both fields pass `hide-bottom-space`, which suppresses the hint
line, so each row is the field alone and a centred icon is what lines up with it. See the note in
`ApiKeyCreateDialog`.
-->
<w-form ref="createSiteForm" class="py-2">
<w-item>
<blueprint-icon icon="home" />
@ -21,7 +26,7 @@
</w-item-section>
</w-item>
<w-item>
<blueprint-icon icon="dns" class="self-start" />
<blueprint-icon icon="dns" />
<w-item-section>
<w-input
v-model="state.siteHostname"

@ -125,6 +125,11 @@ export default {
rar: {
icon: 'img:/_assets/icons/color-rar.svg'
},
// -> Not a file extension, like `folder` and `page`: the type of a page that redirects instead of
// holding content of its own
redirect: {
icon: 'img:/_assets/icons/color-send-file.svg'
},
svg: {
icon: 'img:/_assets/icons/color-image-file.svg'
},

@ -35,6 +35,17 @@
target="_blank">
<w-tooltip>{{ t(`common.actions.viewDocs`) }}</w-tooltip>
</w-btn>
<!-- -> A real href, not a router link: the Swagger UI at `/_api` is served by the backend and is
not part of this SPA. Labelled rather than tooltipped, so the visible text is already the
accessible name and there is no `aria-label` -->
<w-btn
class="acrylic-btn mr-2"
icon="la:book"
flat
color="grey"
:label="t(`admin.api.docsButton`)"
href="/_api"
target="_blank" />
<w-btn
class="acrylic-btn mr-2"
icon="la:redo-alt"

@ -262,6 +262,47 @@
</w-card-actions>
</w-card>
</div>
<div class="col-span-12 lg:col-span-6">
<w-card>
<w-card-section class="admin-dashboard-panel">
<img src="/_assets/icons/fluent-key-2.svg" />
<strong>{{ t('admin.dashboard.lastLogins') }}</strong>
</w-card-section>
<w-separator />
<w-list separator>
<!--
Rows link only where the user list is reachable, the same condition the Users card puts on
its Manage button: the panel itself is `access:admin`, and reading one account is
`read:users`, so for a reader without it a link would land on a refusal.
-->
<w-item
v-for="lastLogin of state.lastLogins"
:key="lastLogin.id"
:clickable="usersAreVisible"
:to="usersAreVisible ? `/_admin/users/` + lastLogin.id : null">
<w-item-section side>
<w-icon name="la:user" :color="actionColor" />
</w-item-section>
<w-item-section>
<w-item-label>{{ lastLogin.name }}</w-item-label>
<w-item-label caption>{{ lastLogin.email }}</w-item-label>
</w-item-section>
<w-item-section side>
<div class="text-caption">{{ relativeDate(lastLogin.lastLoginAt) }}</div>
<!-- -> The exact moment, in the reader's own pattern and zone, behind the rough one -->
<w-tooltip anchor="center left" self="center right">
{{ userStore.formatDateTime(t, lastLogin.lastLoginAt) }}
</w-tooltip>
</w-item-section>
</w-item>
<w-item v-if="state.lastLogins.length < 1">
<w-item-section>
<w-item-label caption>{{ t('admin.dashboard.lastLoginsNone') }}</w-item-label>
</w-item-section>
</w-item>
</w-list>
</w-card>
</div>
</div>
</w-page>
</template>
@ -269,12 +310,13 @@
<script setup>
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { computed, reactive } from 'vue'
import { computed, onMounted, reactive } from 'vue'
import { useMeta } from '@/composables/meta'
import { dialog } from '@/composables/dialog'
import { useDark } from '@/composables/dark'
import { notify } from '@/composables/notify'
import { relativeDate } from '@/helpers/datetime'
import { useFlagsStore } from '@/stores/flags'
import { useSiteStore } from '@/stores/site'
@ -324,7 +366,8 @@ const { t } = useI18n()
// DATA
const state = reactive({
loading: 0
loading: 0,
lastLogins: []
})
// COMPUTED
@ -367,14 +410,31 @@ useMeta({
// METHODS
/*
Every card reads from the admin store, which `AdminLayout` fills once on mount -- `fetchInfo` for
the counters on `info`, `fetchSites` for the sites card, which counts the list itself. Refreshing
the dashboard is therefore both of them, not a call of its own.
The counter cards read from the admin store, which `AdminLayout` fills once on mount -- `fetchInfo`
for the counters on `info`, `fetchSites` for the sites card, which counts the list itself.
The logins panel is fetched here instead, and kept on this page's own state: nothing else shows it,
and the store is filled by the layout that every admin screen mounts, so putting it there would ask
for these rows on every one of them.
*/
// -> Reports its own failure rather than throwing on: one panel that could not be filled is not the
// whole dashboard failing to refresh
async function loadLastLogins() {
try {
state.lastLogins = await API_CLIENT.get('users/recent-logins').json()
} catch (err) {
notify({
type: 'negative',
message: 'Failed to load the last logins.',
caption: err.message
})
}
}
async function load() {
state.loading++
try {
await Promise.all([adminStore.fetchInfo(), adminStore.fetchSites()])
await Promise.all([adminStore.fetchInfo(), adminStore.fetchSites(), loadLastLogins()])
} catch (err) {
notify({
type: 'negative',
@ -385,6 +445,9 @@ async function load() {
state.loading--
}
// -> The store is already filled by the layout; this is the one thing on the page that has to ask
onMounted(loadLastLogins)
function newSite() {
dialog({
component: SiteCreateDialog
@ -415,6 +478,26 @@ function checkForUpdates() {
<style lang="scss">
.admin-dashboard {
/*
Header of a card that holds a list rather than a figure: the same wording weight as `-card` above,
at the smaller icon a title line can carry -- 64px is sized for a card whose whole content is one
number.
*/
&-panel {
display: flex;
align-items: center;
img {
width: 32px;
margin-right: 12px;
}
strong {
font-size: 1.1rem;
font-weight: 300;
}
}
&-card {
display: flex;
align-items: center;

Loading…
Cancel
Save