diff --git a/client/src/pages/admin/AdminUsersTab.test.tsx b/client/src/pages/admin/AdminUsersTab.test.tsx index 3e870a96e..2471924bf 100644 --- a/client/src/pages/admin/AdminUsersTab.test.tsx +++ b/client/src/pages/admin/AdminUsersTab.test.tsx @@ -102,6 +102,15 @@ describe('AdminUsersTab', () => { expect(screen.getByText('A')).toBeInTheDocument(); }); + it('FE-ADMUSR-006b: the column shows the last activity, not only the last sign-in', () => { + renderTab({ + users: [buildAdminUser({ id: 5, username: 'busy', last_login: '2025-06-01T10:30:00.000Z', last_active: '2025-06-09T18:00:00.000Z' })], + }); + + expect(screen.getByRole('columnheader', { name: 'Last Active' })).toBeInTheDocument(); + expect(screen.getByText(/9 Jun|Jun 9/)).toBeInTheDocument(); + }); + it('FE-ADMUSR-006: renders an em dash when the user never logged in', () => { renderTab({ users: [buildAdminUser({ id: 4, username: 'never', last_login: null })] }); diff --git a/client/src/pages/admin/AdminUsersTab.tsx b/client/src/pages/admin/AdminUsersTab.tsx index 73400c5ad..b31b3c4f2 100644 --- a/client/src/pages/admin/AdminUsersTab.tsx +++ b/client/src/pages/admin/AdminUsersTab.tsx @@ -9,7 +9,7 @@ import { EditorField, Segmented } from '../../components/shared/dialogParts' import { SettingsCard, SettingRows, SettingsHint, StatusPill, SETTINGS_BUTTON, SETTINGS_BUTTON_PRIMARY, SETTINGS_ICON_BUTTON } from '../../components/Settings/settingsKit' import type { TranslationFn } from '../../types' import type { useAdmin } from './useAdmin' -import { inviteStatus } from './adminModel' +import { formatLastActive, inviteStatus } from './adminModel' interface AdminUsersTabProps { admin: ReturnType @@ -73,7 +73,7 @@ export default function AdminUsersTab({ admin, t, locale }: AdminUsersTabProps): {t('admin.table.email')} {t('admin.table.role')} {t('admin.table.created')} - {t('admin.table.lastLogin')} + {t('admin.table.lastActive')} {t('admin.table.actions')} @@ -114,7 +114,8 @@ export default function AdminUsersTab({ admin, t, locale }: AdminUsersTabProps): {new Date(u.created_at).toLocaleDateString(locale)} - {u.last_login ? new Date(u.last_login).toLocaleDateString(locale, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', hour12 }) : '—'} + {/* Activity, not only sign-ins: a renewing cookie keeps people signed in for weeks. */} + {formatLastActive(u.last_active ?? u.last_login, locale, hour12)}
diff --git a/client/src/pages/admin/adminModel.test.ts b/client/src/pages/admin/adminModel.test.ts index 36397cdb1..ef0514977 100644 --- a/client/src/pages/admin/adminModel.test.ts +++ b/client/src/pages/admin/adminModel.test.ts @@ -1,7 +1,7 @@ -// FE-ADMMODEL-001 to -005: the invite status both admin user views show. +// FE-ADMMODEL-001 to -005: the invite status both admin user views show; -010 to -012 the users list. import { describe, expect, it } from 'vitest'; -import { inviteStatus } from './adminModel'; +import { formatLastActive, inviteStatus, mergeSavedUser, type AdminUser } from './adminModel'; const NOW = new Date('2026-10-09T12:00:00Z'); @@ -47,3 +47,40 @@ describe('inviteStatus', () => { expect(inviteStatus({ expires_at: '2999-01-01T00:00:00Z', max_uses: 0, used_count: 0 }).isActive).toBe(true); }); }); + +describe('formatLastActive', () => { + it('FE-ADMMODEL-010: a dash for never, the year only once it is not this one', () => { + expect(formatLastActive(null, 'en-GB', false, NOW)).toBe('—'); + const thisYear = formatLastActive('2026-03-04T10:30:00Z', 'en-GB', false, NOW); + const lastYear = formatLastActive('2025-03-04T10:30:00Z', 'en-GB', false, NOW); + expect(thisYear).not.toContain('2026'); + expect(lastYear).toContain('2025'); + }); + + it('FE-ADMMODEL-011: follows the 12 and 24 hour setting', () => { + expect(formatLastActive('2026-03-04T15:30:00Z', 'en-US', true, NOW)).toMatch(/PM/i); + expect(formatLastActive('2026-03-04T15:30:00Z', 'en-US', false, NOW)).not.toMatch(/PM/i); + }); +}); + +describe('mergeSavedUser', () => { + it('FE-ADMMODEL-012: takes the account fields from the reply and keeps the rest of the row', () => { + const row: AdminUser = { + id: 2, + username: 'old', + email: 'old@e.com', + role: 'user', + created_at: '2026-01-01T00:00:00Z', + last_login: '2026-10-01T08:00:00Z', + last_active: '2026-10-08T17:30:00Z', + online: true, + avatar_url: '/a.jpg', + }; + expect(mergeSavedUser(row, { username: 'new', email: 'new@e.com', role: 'admin' })).toEqual({ + ...row, + username: 'new', + email: 'new@e.com', + role: 'admin', + }); + }); +}); diff --git a/client/src/pages/admin/adminModel.ts b/client/src/pages/admin/adminModel.ts index 1a70efc18..5f6e87c42 100644 --- a/client/src/pages/admin/adminModel.ts +++ b/client/src/pages/admin/adminModel.ts @@ -7,11 +7,37 @@ export interface AdminUser { role: 'admin' | 'user' created_at: string last_login?: string | null + /** The later of the last sign-in and the last request the account made. */ + last_active?: string | null online?: boolean oidc_issuer?: string | null avatar_url?: string | null } +/** + * The row after an edit. The update answers with the account fields only, so + * what the list derives (avatar, last activity, online) stays from the row; a + * reply that replaced the whole row showed the user as never signed in. + */ +export function mergeSavedUser(row: AdminUser, saved: Pick): AdminUser { + return { ...row, username: saved.username, email: saved.email, role: saved.role } +} + +/** When a user was last active, with the year once it is not this one. */ +export function formatLastActive(iso: string | null | undefined, locale: string, hour12: boolean, now = new Date()): string { + if (!iso) return '—' + const at = new Date(iso) + const sameYear = at.getFullYear() === now.getFullYear() + return at.toLocaleDateString(locale, { + day: 'numeric', + month: 'short', + ...(sameYear ? {} : { year: 'numeric' }), + hour: '2-digit', + minute: '2-digit', + hour12, + }) +} + export interface AdminStats { totalUsers: number totalTrips: number diff --git a/client/src/pages/admin/useAdmin.test.tsx b/client/src/pages/admin/useAdmin.test.tsx index 747bc8d3c..02fac79d1 100644 --- a/client/src/pages/admin/useAdmin.test.tsx +++ b/client/src/pages/admin/useAdmin.test.tsx @@ -701,6 +701,49 @@ describe('useAdmin', () => { expect(toastCalls).toContainEqual({ type: 'success', message: 'User updated' }); }); + it('FE-ADMHOOK-036d: the open users tab asks again who is online, without the spinner', async () => { + const ticks: Array<() => void> = []; + const spy = vi.spyOn(window, 'setInterval').mockImplementation(((fn: () => void) => { + ticks.push(fn); + return 0; + }) as unknown as typeof window.setInterval); + try { + const { result } = await mountAdmin(); + expect(result.current.users[1].online).not.toBe(true); + server.use(http.get('/api/admin/users', () => HttpResponse.json({ users: [me, { ...alice, online: true }] }))); + await act(async () => { + ticks.forEach((tick) => tick()); + }); + await waitFor(() => expect(result.current.users[1].online).toBe(true)); + expect(result.current.isLoading).toBe(false); + } finally { + spy.mockRestore(); + } + }); + + it('FE-ADMHOOK-036c: a saved row keeps what the list derives (avatar, activity, online)', async () => { + server.use( + http.put('/api/admin/users/:id', ({ params }) => + // The update answers with the account fields only. + HttpResponse.json({ user: { id: Number(params.id), username: 'alice2', email: 'a2@e.com', role: 'user', created_at: '2026-01-01 00:00:00' } }) + ) + ); + const { result } = await mountAdmin(); + const before = result.current.users[1]; + act(() => result.current.handleEditUser(before)); + act(() => result.current.setEditForm({ username: 'alice2', email: 'a2@e.com', role: 'user', password: '' })); + await act(async () => { + await result.current.handleSaveUser(); + }); + + const after = result.current.users[1]; + expect(after).toMatchObject({ username: 'alice2', email: 'a2@e.com' }); + expect(after.last_login).toBe(before.last_login); + expect(after.online).toBe(before.online); + expect(after.avatar_url).toBe(before.avatar_url); + expect(after.created_at).toBe(before.created_at); + }); + it('FE-ADMHOOK-037: handleSaveUser refuses a password shorter than eight characters', async () => { let called = false; server.use( diff --git a/client/src/pages/admin/useAdmin.ts b/client/src/pages/admin/useAdmin.ts index 4f543cc0b..a6a94ce8c 100644 --- a/client/src/pages/admin/useAdmin.ts +++ b/client/src/pages/admin/useAdmin.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { useNavigate, useSearchParams } from 'react-router' import apiClient, { adminApi, authApi } from '../../api/client' import { useAuthStore } from '../../store/authStore' @@ -8,7 +8,7 @@ import { useTranslation } from '../../i18n' import { getApiErrorMessage } from '../../types' import { useToast } from '../../components/shared/Toast' import { managedAdminTabs } from '../../managed' -import type { AdminUser, AdminStats, OidcConfig, UpdateInfo } from './adminModel' +import { mergeSavedUser, type AdminUser, type AdminStats, type OidcConfig, type UpdateInfo } from './adminModel' import type { TransitProvider, TransitKeySource } from '@trek/shared' import { passwordErrorKey } from '../../utils/passwordError' @@ -35,6 +35,9 @@ const ADMIN_TAB_IDS = [ */ const MANAGED_HIDDEN = ['storage', 'github', 'backup'] +/** How often the open users list asks again who is online and active. */ +const USERS_REFRESH_MS = 30_000 + /** * Admin page logic — owns every admin data slice (users, stats, invites, auth * toggles, OIDC, feature flags, API keys, SMTP, version/update) plus the CRUD @@ -246,6 +249,34 @@ export function useAdmin() { } } + // Who is online and when someone was last active change while the list is + // open; it was read once and went stale until a reload. Refreshed quietly + // while the users tab is in view, and at once when the page comes back. + // Bumped by every local change to the list; a refresh asked before one is out of date. + const usersWrites = useRef(0) + useEffect(() => { + if (activeTab !== 'users') return + let cancelled = false + const refresh = async () => { + if (document.visibilityState !== 'visible') return + const asked = usersWrites.current + try { + const data = await adminApi.users() + if (!cancelled && asked === usersWrites.current) setUsers(data.users) + } catch { + /* the list on screen stays; the next tick asks again */ + } + } + const timer = window.setInterval(() => { void refresh() }, USERS_REFRESH_MS) + const onVisible = () => { void refresh() } + document.addEventListener('visibilitychange', onVisible) + return () => { + cancelled = true + window.clearInterval(timer) + document.removeEventListener('visibilitychange', onVisible) + } + }, [activeTab]) + const loadAppConfig = async () => { try { const config = await authApi.getAppConfig() @@ -420,6 +451,7 @@ export function useAdmin() { } try { const data = await adminApi.createUser(createForm) + usersWrites.current += 1 setUsers(prev => [data.user, ...prev]) setShowCreateUser(false) setCreateForm({ username: '', email: '', password: '', role: 'user' }) @@ -484,7 +516,8 @@ export function useAdmin() { payload.password = editForm.password.trim() } const data = await adminApi.updateUser(editingUser.id, payload) - setUsers(prev => prev.map(u => u.id === editingUser.id ? data.user : u)) + usersWrites.current += 1 + setUsers(prev => prev.map(u => u.id === editingUser.id ? mergeSavedUser(u, data.user) : u)) setEditingUser(null) toast.success(t('admin.toast.userUpdated')) } catch (err: unknown) { @@ -502,6 +535,7 @@ export function useAdmin() { if (!opts.confirmed && !confirm(t('admin.deleteUser', { name: user.username }))) return try { await adminApi.deleteUser(user.id) + usersWrites.current += 1 setUsers(prev => prev.filter(u => u.id !== user.id)) toast.success(t('admin.toast.userDeleted')) } catch (err: unknown) { diff --git a/server/src/db/repositories/UserSessions.repository.ts b/server/src/db/repositories/UserSessions.repository.ts index 24b4fd1d6..22aa0516d 100644 --- a/server/src/db/repositories/UserSessions.repository.ts +++ b/server/src/db/repositories/UserSessions.repository.ts @@ -1,5 +1,6 @@ import type { UserSessions } from '../entities/UserSessions.entity'; import { Users } from '../entities/Users.entity'; +import type { DB } from '../kysely/db'; import type { AssertRowKeys } from './_shared/rows'; import { TrekRepository } from './_shared/trek-repository'; @@ -154,6 +155,20 @@ export class UserSessionsRepository extends TrekRepository { ); } + /** + * `SELECT user_id, MAX(last_seen_at) FROM user_sessions GROUP BY user_id`: when + * each account last made an authenticated request, over every row the nightly + * purge has kept, revoked ones included (the activity still happened). + */ + async lastSeenByUser(): Promise> { + const rows = await this.kysely>() + .selectFrom('user_sessions') + .select((eb) => ['user_id', eb.fn.max('last_seen_at').as('last_seen_at')]) + .groupBy('user_id') + .execute(); + return new Map(rows.map((row) => [Number(row.user_id), String(row.last_seen_at)])); + } + /** * Every active session with its owner's email, read before the database * file is swapped (a demo reset, a backup restore): diff --git a/server/src/nest/admin/admin.service.ts b/server/src/nest/admin/admin.service.ts index 254baf50a..9302b7dd7 100644 --- a/server/src/nest/admin/admin.service.ts +++ b/server/src/nest/admin/admin.service.ts @@ -108,6 +108,13 @@ const VERSION_FAILURE_TTL = 60_000; * and module-scoped pieces (compareVersions, isDocker, the version cache) live * in admin.helpers.ts. */ +/** The later of two stored timestamps; both are canonical UTC text, which sorts as it reads. */ +function latest(a: string | null | undefined, b: string | null | undefined): string | null { + if (!a) return b ?? null; + if (!b) return a; + return a > b ? a : b; +} + /** The 409 the up-front checks give, for a write that lost the race to another one. */ function identityTakenMessage(err: UserIdentityTakenError): string { return err.field === 'email' ? 'Email already taken' : 'Username already taken'; @@ -148,6 +155,7 @@ export class AdminService { // Guests (#1362) are accountless trip participants, not real users — keep them out // of admin user management entirely. const users = await this.users.listForAdmin(); + const lastSeen = await this.sessions.lastSeenByUser(); let onlineUserIds = new Set(); try { // The catch stays here rather than in the facade: an admin list that shows @@ -163,6 +171,10 @@ export class AdminService { created_at: utcSuffix(u.created_at), updated_at: utcSuffix(u.updated_at as string), last_login: utcSuffix(u.last_login), + // The later of the last sign-in and the last request. The cookie renews + // itself while someone keeps using TREK, so they may not have signed in + // for weeks and still have been here an hour ago. + last_active: utcSuffix(latest(u.last_login, lastSeen.get(u.id))), online: onlineUserIds.has(u.id), })); } diff --git a/server/src/nest/sessions/sessions.service.ts b/server/src/nest/sessions/sessions.service.ts index 503598ccb..1b32cf1dd 100644 --- a/server/src/nest/sessions/sessions.service.ts +++ b/server/src/nest/sessions/sessions.service.ts @@ -164,6 +164,15 @@ export class SessionsService { await this.sessions.touchLastSeen(id, now); } + /** + * When each account was last active: the newest `last_seen_at` of its + * sessions. A sign-in is rare once the cookie renews itself on every visit, + * so this is what tells an admin who is still using the instance. + */ + async lastSeenByUser(): Promise> { + return this.sessions.lastSeenByUser(); + } + /** The user's active sessions, most recently used first, the one in `currentId` flagged. */ async list(userId: number, currentId?: string): Promise { const rows = await this.sessions.listActiveForUser(userId, dbNow()); diff --git a/server/tests/unit/db/repositories/UserSessions.repository.test.ts b/server/tests/unit/db/repositories/UserSessions.repository.test.ts index b3dba7016..8f841721d 100644 --- a/server/tests/unit/db/repositories/UserSessions.repository.test.ts +++ b/server/tests/unit/db/repositories/UserSessions.repository.test.ts @@ -274,4 +274,18 @@ describe('UserSessionsRepository', () => { user_agent: 'First', }); }); + it('SESSREPO-014: lastSeenByUser gives each account its newest last_seen_at, revoked rows included', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + const { user: idle } = createUser(testDb); + await add('a', user.id); + await add('b', user.id); + await add('c', other.id); + await sessions.touchLastSeen('a', NOW); + await revoke(['c'], NOW); + const seen = await sessions.lastSeenByUser(); + expect(seen.get(user.id)).toBe(NOW); + expect(seen.get(other.id)).toBe(EARLIER); + expect(seen.has(idle.id)).toBe(false); + }); }); diff --git a/server/tests/unit/nest/admin.service.test.ts b/server/tests/unit/nest/admin.service.test.ts index f65cf4bff..58e669b4e 100644 --- a/server/tests/unit/nest/admin.service.test.ts +++ b/server/tests/unit/nest/admin.service.test.ts @@ -275,6 +275,34 @@ describe('listUsers', () => { expect(users.length).toBeGreaterThanOrEqual(2); expect(users.every((u: any) => u.online === false)).toBe(true); }); + + it('ADMIN-SVC-001b — last_active is the later of the last sign-in and the last request', async () => { + const { user: busy } = createUser(testDb); + const { user: quiet } = createUser(testDb); + const { user: never } = createUser(testDb); + await updateRows(orm, Users, { id: busy.id }, { last_login: '2026-10-01 08:00:00' }); + await updateRows(orm, Users, { id: quiet.id }, { last_login: '2026-10-05 08:00:00' }); + // The cookie renewed itself for a week: no new sign-in, but requests until the 8th. + await insertRow(orm, UserSessions, { + id: 'busy-session', + user: busy.id, + created_at: '2026-10-01 08:00:00', + last_seen_at: '2026-10-08 17:30:00', + expires_at: '2999-01-01 00:00:00', + }); + await insertRow(orm, UserSessions, { + id: 'quiet-session', + user: quiet.id, + created_at: '2026-10-02 08:00:00', + last_seen_at: '2026-10-02 09:00:00', + expires_at: '2999-01-01 00:00:00', + }); + const users = await listUsers(); + const of = (id: number) => users.find((u) => u.id === id); + expect(of(busy.id)).toMatchObject({ last_login: '2026-10-01T08:00:00Z', last_active: '2026-10-08T17:30:00Z' }); + expect(of(quiet.id)).toMatchObject({ last_login: '2026-10-05T08:00:00Z', last_active: '2026-10-05T08:00:00Z' }); + expect(of(never.id)).toMatchObject({ last_login: null, last_active: null }); + }); }); // ── createUser ──────────────────────────────────────────────────────────────── diff --git a/shared/src/i18n/ar/admin.ts b/shared/src/i18n/ar/admin.ts index 95ca432ef..e14806ba8 100644 --- a/shared/src/i18n/ar/admin.ts +++ b/shared/src/i18n/ar/admin.ts @@ -92,7 +92,7 @@ const admin: TranslationStrings = { 'admin.table.email': 'البريد الإلكتروني', 'admin.table.role': 'الدور', 'admin.table.created': 'تم الإنشاء', - 'admin.table.lastLogin': 'آخر تسجيل دخول', + 'admin.table.lastActive': 'آخر نشاط', 'admin.table.actions': 'الإجراءات', 'admin.you': '(أنت)', 'admin.editUser': 'تعديل المستخدم', diff --git a/shared/src/i18n/az/admin.ts b/shared/src/i18n/az/admin.ts index 5562b2c9a..d64885c1e 100644 --- a/shared/src/i18n/az/admin.ts +++ b/shared/src/i18n/az/admin.ts @@ -91,7 +91,7 @@ const admin: TranslationStrings = { 'admin.table.email': 'E-poçt', 'admin.table.role': 'Rol', 'admin.table.created': 'Yaradılıb', - 'admin.table.lastLogin': 'Son giriş', + 'admin.table.lastActive': 'Son aktivlik', 'admin.table.actions': 'Əməliyyatlar', 'admin.you': '(Siz)', 'admin.editUser': 'İstifadəçini redaktə et', diff --git a/shared/src/i18n/br/admin.ts b/shared/src/i18n/br/admin.ts index efdcf9aed..57691dea7 100644 --- a/shared/src/i18n/br/admin.ts +++ b/shared/src/i18n/br/admin.ts @@ -35,7 +35,7 @@ const admin: TranslationStrings = { 'admin.table.email': 'E-mail', 'admin.table.role': 'Função', 'admin.table.created': 'Criado', - 'admin.table.lastLogin': 'Último acesso', + 'admin.table.lastActive': 'Última atividade', 'admin.table.actions': 'Ações', 'admin.you': '(Você)', 'admin.editUser': 'Editar usuário', diff --git a/shared/src/i18n/ca/admin.ts b/shared/src/i18n/ca/admin.ts index 6a366761d..52c321bd6 100644 --- a/shared/src/i18n/ca/admin.ts +++ b/shared/src/i18n/ca/admin.ts @@ -36,7 +36,7 @@ const admin: TranslationStrings = { 'admin.table.email': 'Correu', 'admin.table.role': 'Rol', 'admin.table.created': 'Creat', - 'admin.table.lastLogin': 'Últim accés', + 'admin.table.lastActive': 'Última activitat', 'admin.table.actions': 'Accions', 'admin.you': '(Tu)', 'admin.editUser': 'Editar usuari', diff --git a/shared/src/i18n/cs/admin.ts b/shared/src/i18n/cs/admin.ts index e1f2a817a..0813deaeb 100644 --- a/shared/src/i18n/cs/admin.ts +++ b/shared/src/i18n/cs/admin.ts @@ -39,7 +39,7 @@ const admin: TranslationStrings = { 'admin.table.email': 'E-mail', 'admin.table.role': 'Role', 'admin.table.created': 'Vytvořeno', - 'admin.table.lastLogin': 'Poslední přihlášení', + 'admin.table.lastActive': 'Naposledy aktivní', 'admin.table.actions': 'Akce', 'admin.you': '(Vy)', 'admin.editUser': 'Upravit uživatele', diff --git a/shared/src/i18n/de/admin.ts b/shared/src/i18n/de/admin.ts index bdbd3f666..2a2795360 100644 --- a/shared/src/i18n/de/admin.ts +++ b/shared/src/i18n/de/admin.ts @@ -36,7 +36,7 @@ const admin: TranslationStrings = { 'admin.table.email': 'E-Mail', 'admin.table.role': 'Rolle', 'admin.table.created': 'Erstellt', - 'admin.table.lastLogin': 'Letzter Login', + 'admin.table.lastActive': 'Zuletzt aktiv', 'admin.table.actions': 'Aktionen', 'admin.you': '(Du)', 'admin.editUser': 'Benutzer bearbeiten', diff --git a/shared/src/i18n/en/admin.ts b/shared/src/i18n/en/admin.ts index d5f73c693..099a0ea99 100644 --- a/shared/src/i18n/en/admin.ts +++ b/shared/src/i18n/en/admin.ts @@ -87,7 +87,7 @@ const admin: TranslationStrings = { 'admin.table.email': 'Email', 'admin.table.role': 'Role', 'admin.table.created': 'Created', - 'admin.table.lastLogin': 'Last Login', + 'admin.table.lastActive': 'Last Active', 'admin.table.actions': 'Actions', 'admin.you': '(You)', 'admin.editUser': 'Edit User', diff --git a/shared/src/i18n/es/admin.ts b/shared/src/i18n/es/admin.ts index 7e6e94f99..b5816107b 100644 --- a/shared/src/i18n/es/admin.ts +++ b/shared/src/i18n/es/admin.ts @@ -36,7 +36,7 @@ const admin: TranslationStrings = { 'admin.table.email': 'Correo', 'admin.table.role': 'Rol', 'admin.table.created': 'Creado', - 'admin.table.lastLogin': 'Último acceso', + 'admin.table.lastActive': 'Última actividad', 'admin.table.actions': 'Acciones', 'admin.you': '(Tú)', 'admin.editUser': 'Editar usuario', diff --git a/shared/src/i18n/et/admin.ts b/shared/src/i18n/et/admin.ts index e392dc0a5..ad1f406d8 100644 --- a/shared/src/i18n/et/admin.ts +++ b/shared/src/i18n/et/admin.ts @@ -89,7 +89,7 @@ const admin: TranslationStrings = { 'admin.table.email': 'E-post', 'admin.table.role': 'Roll', 'admin.table.created': 'Loodud', - 'admin.table.lastLogin': 'Viimane sisselogimine', + 'admin.table.lastActive': 'Viimati aktiivne', 'admin.table.actions': 'Toimingud', 'admin.you': '(Sina)', 'admin.editUser': 'Muuda kasutajat', diff --git a/shared/src/i18n/fr/admin.ts b/shared/src/i18n/fr/admin.ts index 8e24c44c5..b2a121cd1 100644 --- a/shared/src/i18n/fr/admin.ts +++ b/shared/src/i18n/fr/admin.ts @@ -35,7 +35,7 @@ const admin: TranslationStrings = { 'admin.table.email': 'E-mail', 'admin.table.role': 'Rôle', 'admin.table.created': 'Créé le', - 'admin.table.lastLogin': 'Dernière connexion', + 'admin.table.lastActive': 'Dernière activité', 'admin.table.actions': 'Actions', 'admin.you': '(Vous)', 'admin.editUser': "Modifier l'utilisateur", diff --git a/shared/src/i18n/gr/admin.ts b/shared/src/i18n/gr/admin.ts index e043d6f3b..a6a78736c 100644 --- a/shared/src/i18n/gr/admin.ts +++ b/shared/src/i18n/gr/admin.ts @@ -91,7 +91,7 @@ const admin: TranslationStrings = { 'admin.table.email': 'Email', 'admin.table.role': 'Ρόλος', 'admin.table.created': 'Δημιουργήθηκε', - 'admin.table.lastLogin': 'Τελευταία Σύνδεση', + 'admin.table.lastActive': 'Τελευταία Δραστηριότητα', 'admin.table.actions': 'Ενέργειες', 'admin.you': '(Εσείς)', 'admin.editUser': 'Επεξεργασία Χρήστη', diff --git a/shared/src/i18n/hu/admin.ts b/shared/src/i18n/hu/admin.ts index b0145622c..f9bf34df3 100644 --- a/shared/src/i18n/hu/admin.ts +++ b/shared/src/i18n/hu/admin.ts @@ -35,7 +35,7 @@ const admin: TranslationStrings = { 'admin.table.email': 'E-mail', 'admin.table.role': 'Szerepkör', 'admin.table.created': 'Létrehozva', - 'admin.table.lastLogin': 'Utolsó belépés', + 'admin.table.lastActive': 'Utoljára aktív', 'admin.table.actions': 'Műveletek', 'admin.you': '(Te)', 'admin.editUser': 'Felhasználó szerkesztése', diff --git a/shared/src/i18n/id/admin.ts b/shared/src/i18n/id/admin.ts index a4dd8dc14..f72bd1fd5 100644 --- a/shared/src/i18n/id/admin.ts +++ b/shared/src/i18n/id/admin.ts @@ -83,7 +83,7 @@ const admin: TranslationStrings = { 'admin.table.email': 'Email', 'admin.table.role': 'Peran', 'admin.table.created': 'Dibuat', - 'admin.table.lastLogin': 'Login Terakhir', + 'admin.table.lastActive': 'Terakhir Aktif', 'admin.table.actions': 'Tindakan', 'admin.you': '(Kamu)', 'admin.editUser': 'Edit Pengguna', diff --git a/shared/src/i18n/it/admin.ts b/shared/src/i18n/it/admin.ts index 119499063..e8b16dc53 100644 --- a/shared/src/i18n/it/admin.ts +++ b/shared/src/i18n/it/admin.ts @@ -35,7 +35,7 @@ const admin: TranslationStrings = { 'admin.table.email': 'Email', 'admin.table.role': 'Ruolo', 'admin.table.created': 'Creato', - 'admin.table.lastLogin': 'Ultimo Accesso', + 'admin.table.lastActive': 'Ultima Attività', 'admin.table.actions': 'Azioni', 'admin.you': '(Tu)', 'admin.editUser': 'Modifica Utente', diff --git a/shared/src/i18n/ja/admin.ts b/shared/src/i18n/ja/admin.ts index e6ef2aaa5..4a4677c78 100644 --- a/shared/src/i18n/ja/admin.ts +++ b/shared/src/i18n/ja/admin.ts @@ -77,7 +77,7 @@ const admin: TranslationStrings = { 'admin.table.email': 'メール', 'admin.table.role': '権限', 'admin.table.created': '作成日', - 'admin.table.lastLogin': '最終ログイン', + 'admin.table.lastActive': '最終アクティブ', 'admin.table.actions': '操作', 'admin.you': '(あなた)', 'admin.editUser': 'ユーザーを編集', diff --git a/shared/src/i18n/ko/admin.ts b/shared/src/i18n/ko/admin.ts index aa64f2bc9..0d152a6e0 100644 --- a/shared/src/i18n/ko/admin.ts +++ b/shared/src/i18n/ko/admin.ts @@ -82,7 +82,7 @@ const admin: TranslationStrings = { 'admin.table.email': '이메일', 'admin.table.role': '역할', 'admin.table.created': '생성일', - 'admin.table.lastLogin': '마지막 로그인', + 'admin.table.lastActive': '마지막 활동', 'admin.table.actions': '작업', 'admin.you': '(나)', 'admin.editUser': '사용자 편집', diff --git a/shared/src/i18n/nl/admin.ts b/shared/src/i18n/nl/admin.ts index ae5e882a1..6496fb137 100644 --- a/shared/src/i18n/nl/admin.ts +++ b/shared/src/i18n/nl/admin.ts @@ -36,7 +36,7 @@ const admin: TranslationStrings = { 'admin.table.email': 'E-mail', 'admin.table.role': 'Rol', 'admin.table.created': 'Aangemaakt', - 'admin.table.lastLogin': 'Laatste login', + 'admin.table.lastActive': 'Laatst actief', 'admin.table.actions': 'Acties', 'admin.you': '(Jij)', 'admin.editUser': 'Gebruiker bewerken', diff --git a/shared/src/i18n/pl/admin.ts b/shared/src/i18n/pl/admin.ts index e66fb39fe..dad6f4f01 100644 --- a/shared/src/i18n/pl/admin.ts +++ b/shared/src/i18n/pl/admin.ts @@ -34,7 +34,7 @@ const admin: TranslationStrings = { 'admin.table.email': 'E-mail', 'admin.table.role': 'Rola', 'admin.table.created': 'Utworzono', - 'admin.table.lastLogin': 'Ostatnie logowanie', + 'admin.table.lastActive': 'Ostatnia aktywność', 'admin.table.actions': 'Akcje', 'admin.you': '(Ty)', 'admin.editUser': 'Edytuj użytkownika', diff --git a/shared/src/i18n/ru/admin.ts b/shared/src/i18n/ru/admin.ts index b64bfadbd..68cded24f 100644 --- a/shared/src/i18n/ru/admin.ts +++ b/shared/src/i18n/ru/admin.ts @@ -44,7 +44,7 @@ const admin: TranslationStrings = { 'admin.table.email': 'Эл. почта', 'admin.table.role': 'Роль', 'admin.table.created': 'Создан', - 'admin.table.lastLogin': 'Последний вход', + 'admin.table.lastActive': 'Последняя активность', 'admin.table.actions': 'Действия', 'admin.you': '(Вы)', 'admin.editUser': 'Редактировать пользователя', diff --git a/shared/src/i18n/sk/admin.ts b/shared/src/i18n/sk/admin.ts index 78c8bb79a..c005acf42 100644 --- a/shared/src/i18n/sk/admin.ts +++ b/shared/src/i18n/sk/admin.ts @@ -91,7 +91,7 @@ const admin: TranslationStrings = { 'admin.table.email': 'E-mail', 'admin.table.role': 'Rola', 'admin.table.created': 'Vytvorené', - 'admin.table.lastLogin': 'Posledné prihlásenie', + 'admin.table.lastActive': 'Naposledy aktívny', 'admin.table.actions': 'Akcie', 'admin.you': '(Vy)', 'admin.editUser': 'Upraviť používateľa', diff --git a/shared/src/i18n/sv/admin.ts b/shared/src/i18n/sv/admin.ts index dea92b916..6263ba486 100644 --- a/shared/src/i18n/sv/admin.ts +++ b/shared/src/i18n/sv/admin.ts @@ -88,7 +88,7 @@ const admin: TranslationStrings = { 'admin.table.email': 'E-post', 'admin.table.role': 'Roll', 'admin.table.created': 'Skapad', - 'admin.table.lastLogin': 'Senast inloggad', + 'admin.table.lastActive': 'Senast aktiv', 'admin.table.actions': 'Åtgärder', 'admin.you': '(Du)', 'admin.editUser': 'Redigera anvädnare', diff --git a/shared/src/i18n/th/admin.ts b/shared/src/i18n/th/admin.ts index 7a5312e55..d63da8b25 100644 --- a/shared/src/i18n/th/admin.ts +++ b/shared/src/i18n/th/admin.ts @@ -83,7 +83,7 @@ const admin: TranslationStrings = { 'admin.table.email': 'อีเมล', 'admin.table.role': 'บทบาท', 'admin.table.created': 'สร้างแล้ว', - 'admin.table.lastLogin': 'เข้าสู่ระบบครั้งล่าสุด', + 'admin.table.lastActive': 'ใช้งานล่าสุด', 'admin.table.actions': 'การดำเนินการ', 'admin.you': '(คุณ)', 'admin.editUser': 'แก้ไขผู้ใช้', diff --git a/shared/src/i18n/tr/admin.ts b/shared/src/i18n/tr/admin.ts index 7fa6b2b91..4ac6e3a61 100644 --- a/shared/src/i18n/tr/admin.ts +++ b/shared/src/i18n/tr/admin.ts @@ -88,7 +88,7 @@ const admin: TranslationStrings = { 'admin.table.email': 'E-posta', 'admin.table.role': 'Rol', 'admin.table.created': 'Oluşturulma', - 'admin.table.lastLogin': 'Son Giriş', + 'admin.table.lastActive': 'Son Etkinlik', 'admin.table.actions': 'İşlemler', 'admin.you': '(Siz)', 'admin.editUser': 'Kullanıcıyı Düzenle', diff --git a/shared/src/i18n/uk/admin.ts b/shared/src/i18n/uk/admin.ts index fb33ec561..eacb0fa27 100644 --- a/shared/src/i18n/uk/admin.ts +++ b/shared/src/i18n/uk/admin.ts @@ -44,7 +44,7 @@ const admin: TranslationStrings = { 'admin.table.email': 'Ел. пошта', 'admin.table.role': 'Роль', 'admin.table.created': 'Створено', - 'admin.table.lastLogin': 'Останній вхід', + 'admin.table.lastActive': 'Остання активність', 'admin.table.actions': 'Дії', 'admin.you': '(Ви)', 'admin.editUser': 'Редагувати користувача', diff --git a/shared/src/i18n/vi/admin.ts b/shared/src/i18n/vi/admin.ts index aace92cdc..564a21641 100644 --- a/shared/src/i18n/vi/admin.ts +++ b/shared/src/i18n/vi/admin.ts @@ -84,7 +84,7 @@ const admin: TranslationStrings = { 'admin.table.email': 'Email', 'admin.table.role': 'Vai trò', 'admin.table.created': 'Tạo', - 'admin.table.lastLogin': 'Đăng nhập lần cuối', + 'admin.table.lastActive': 'Hoạt động lần cuối', 'admin.table.actions': 'hành động', 'admin.you': '(Bạn)', 'admin.editUser': 'Chỉnh sửa người dùng', diff --git a/shared/src/i18n/zh-TW/admin.ts b/shared/src/i18n/zh-TW/admin.ts index f2f701c3a..113d1dbc1 100644 --- a/shared/src/i18n/zh-TW/admin.ts +++ b/shared/src/i18n/zh-TW/admin.ts @@ -80,7 +80,7 @@ const admin: TranslationStrings = { 'admin.table.email': '郵箱', 'admin.table.role': '角色', 'admin.table.created': '建立時間', - 'admin.table.lastLogin': '最後登入', + 'admin.table.lastActive': '最後活動', 'admin.table.actions': '操作', 'admin.you': '(你)', 'admin.editUser': '編輯使用者', diff --git a/shared/src/i18n/zh/admin.ts b/shared/src/i18n/zh/admin.ts index 43e792916..51f717f1b 100644 --- a/shared/src/i18n/zh/admin.ts +++ b/shared/src/i18n/zh/admin.ts @@ -32,7 +32,7 @@ const admin: TranslationStrings = { 'admin.table.email': '邮箱', 'admin.table.role': '角色', 'admin.table.created': '创建时间', - 'admin.table.lastLogin': '最后登录', + 'admin.table.lastActive': '最后活动', 'admin.table.actions': '操作', 'admin.you': '(你)', 'admin.editUser': '编辑用户', diff --git a/wiki/Admin-Users-and-Invites.md b/wiki/Admin-Users-and-Invites.md index ed400272c..dcd5584d0 100644 --- a/wiki/Admin-Users-and-Invites.md +++ b/wiki/Admin-Users-and-Invites.md @@ -10,13 +10,15 @@ The **Users** card shows the number of accounts in its head and a table of every | Column | Description | |--------|-------------| -| **User** | Avatar, username, and an always-visible presence dot (green = online, grey = offline) | +| **User** | Avatar, username, and an always-visible presence dot (green = has TREK open, grey = offline) | | **Email** | Account email address | | **Role** | Badge showing **Administrator** or **User** | | **Created** | Account creation date | -| **Last Login** | Date and time of most recent login | +| **Last Active** | When the account last signed in or used TREK, whichever is later. A session renews itself while it is used, so someone can be active daily without signing in for weeks. The year shows once it is not the current one. | | **Actions** | Edit and delete buttons | +The dots and the last activity refresh every 30 seconds while the list is open, and right away when you come back to the tab. + Your own row is highlighted and marked **(You)**. Its delete button is disabled: you cannot delete your own account. ## User actions