fix(admin): show when users were last active and keep the list current

The column showed the last sign-in, which a renewing cookie leaves weeks
behind for someone using TREK daily. It now shows the later of the sign-in
and the last request. Saving an edit no longer blanks the row's avatar,
activity and online dot, and the list refreshes every 30 seconds while open.
This commit is contained in:
Maurice
2026-10-14 10:45:39 +02:00
committed by Maurice
parent 4ceade9d8b
commit 3c8fa07f44
39 changed files with 267 additions and 37 deletions
@@ -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 })] });
+4 -3
View File
@@ -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<typeof useAdmin>
@@ -73,7 +73,7 @@ export default function AdminUsersTab({ admin, t, locale }: AdminUsersTabProps):
<th className={TH}>{t('admin.table.email')}</th>
<th className={TH}>{t('admin.table.role')}</th>
<th className={TH}>{t('admin.table.created')}</th>
<th className={TH}>{t('admin.table.lastLogin')}</th>
<th className={TH}>{t('admin.table.lastActive')}</th>
<th className={`${TH} text-end`}>{t('admin.table.actions')}</th>
</tr>
</thead>
@@ -114,7 +114,8 @@ export default function AdminUsersTab({ admin, t, locale }: AdminUsersTabProps):
{new Date(u.created_at).toLocaleDateString(locale)}
</td>
<td className={`${TD} whitespace-nowrap font-geist tabular-nums text-content-muted`} style={fs(12)}>
{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)}
</td>
<td className={TD}>
<div className="flex items-center justify-end gap-1.5">
+39 -2
View File
@@ -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',
});
});
});
+26
View File
@@ -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, 'username' | 'email' | 'role'>): 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
+43
View File
@@ -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(
+37 -3
View File
@@ -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) {
@@ -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<UserSessions> {
);
}
/**
* `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<Map<number, string>> {
const rows = await this.kysely<Pick<DB, 'user_sessions'>>()
.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):
+12
View File
@@ -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<number>();
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),
}));
}
@@ -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<Map<number, string>> {
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<UserSession[]> {
const rows = await this.sessions.listActiveForUser(userId, dbNow());
@@ -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);
});
});
@@ -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 ────────────────────────────────────────────────────────────────
+1 -1
View File
@@ -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': 'تعديل المستخدم',
+1 -1
View File
@@ -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',
+1 -1
View File
@@ -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',
+1 -1
View File
@@ -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',
+1 -1
View File
@@ -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',
+1 -1
View File
@@ -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',
+1 -1
View File
@@ -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',
+1 -1
View File
@@ -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',
+1 -1
View File
@@ -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',
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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': 'Επεξεργασία Χρήστη',
+1 -1
View File
@@ -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',
+1 -1
View File
@@ -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',
+1 -1
View File
@@ -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',
+1 -1
View File
@@ -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': 'ユーザーを編集',
+1 -1
View File
@@ -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': '사용자 편집',
+1 -1
View File
@@ -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',
+1 -1
View File
@@ -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',
+1 -1
View File
@@ -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': 'Редактировать пользователя',
+1 -1
View File
@@ -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',
+1 -1
View File
@@ -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',
+1 -1
View File
@@ -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': 'แก้ไขผู้ใช้',
+1 -1
View File
@@ -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',
+1 -1
View File
@@ -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': 'Редагувати користувача',
+1 -1
View File
@@ -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',
+1 -1
View File
@@ -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': '編輯使用者',
+1 -1
View File
@@ -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': '编辑用户',
+4 -2
View File
@@ -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