mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 22:16:26 +00:00
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:
@@ -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 })] });
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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': 'تعديل المستخدم',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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': 'Επεξεργασία Χρήστη',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'ユーザーを編集',
|
||||
|
||||
@@ -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': '사용자 편집',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Редактировать пользователя',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'แก้ไขผู้ใช้',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Редагувати користувача',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '編輯使用者',
|
||||
|
||||
@@ -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': '编辑用户',
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user