Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions src/components/Firebase/Firebase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,10 @@ class Firebase {
await this.db.collection('users').doc(userCredential.user.uid).update({
lastLogin: firebase.firestore.FieldValue.serverTimestamp()
})
await this.db.collection('loginEvents').add({
userId: userCredential.user.uid,
timestamp: firebase.firestore.FieldValue.serverTimestamp()
})
}
return userCredential
})
Expand Down Expand Up @@ -4844,6 +4848,26 @@ class Firebase {
return lastActionMap
}

getUsersLoginCounts = async (
startDate: Date,
endDate: Date
): Promise<Map<string, number>> => {
const counts = new Map<string, number>()
const snapshot = await this.db
.collection('loginEvents')
.where('timestamp', '>=', startDate)
.where('timestamp', '<=', endDate)
.get()

snapshot.docs.forEach(doc => {
const userId = doc.data().userId
if (!userId) return
counts.set(userId, (counts.get(userId) || 0) + 1)
})

return counts
}

getAllUsers = async () => {
const result: Array<{
id: string
Expand Down
70 changes: 62 additions & 8 deletions src/components/UsersComponents/AllUsersTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import {
Switch,
Tooltip,
} from '@material-ui/core'
import { MuiPickersUtilsProvider, KeyboardDatePicker } from '@material-ui/pickers'
import DateFnsUtils from '@date-io/date-fns'
import React from 'react'
import GetAppIcon from '@material-ui/icons/GetApp'
import EditIcon from '@material-ui/icons/Edit'
Expand Down Expand Up @@ -43,6 +45,10 @@ const StatusBadge = styled.span<{ archived: boolean }>`

interface Props {
users: Types.User[]
loginCounts: Map<string, number>
rangeStart: Date
rangeEnd: Date
onRangeChange: (start: Date, end: Date) => void
onUserClick?: (user: Types.User) => void
onArchiveClick?: (user: Types.User) => void
}
Expand Down Expand Up @@ -90,12 +96,19 @@ class AllUsersTable extends React.Component<Props, State> {

users.sort((a, b) => {
const isDateField = sortField === 'lastLogin' || sortField === 'lastAction'
const aVal = isDateField
? (a[sortField]?.getTime() || 0)
: String(a[sortField] || '').toLowerCase()
const bVal = isDateField
? (b[sortField]?.getTime() || 0)
: String(b[sortField] || '').toLowerCase()
const isLoginCount = sortField === 'loginCount'
let aVal: number | string
let bVal: number | string
if (isLoginCount) {
aVal = this.props.loginCounts.get(a.id) || 0
bVal = this.props.loginCounts.get(b.id) || 0
} else if (isDateField) {
aVal = a[sortField]?.getTime() || 0
bVal = b[sortField]?.getTime() || 0
} else {
aVal = String(a[sortField] || '').toLowerCase()
bVal = String(b[sortField] || '').toLowerCase()
}
return sortDir === 'asc' ? (aVal > bVal ? 1 : -1) : (aVal < bVal ? 1 : -1)
})

Expand All @@ -115,15 +128,33 @@ class AllUsersTable extends React.Component<Props, State> {
return user.lastActionType ? `${user.lastActionType} - ${date}` : date
}

formatRangeLabel = () => {
const fmt = (d: Date) => d.toLocaleDateString([], { month: 'short', day: 'numeric' })
return `${fmt(this.props.rangeStart)} \u2013 ${fmt(this.props.rangeEnd)}`
}

applyPreset = (days: number) => {
const end = new Date()
end.setHours(23, 59, 59, 999)
const start = new Date()
start.setDate(start.getDate() - days + 1)
start.setHours(0, 0, 0, 0)
this.props.onRangeChange(start, end)
}

handleExport = () => {
const users = this.getFilteredUsers()
const headers = ['Last Name', 'First Name', 'Email', 'Role', 'Program', 'Status', 'Last Login', 'Last Action', 'Action Type']
const headers = [
'Last Name', 'First Name', 'Email', 'Role', 'Program', 'Status',
'Last Login', `Logins (${this.formatRangeLabel()})`, 'Last Action', 'Action Type'
]
const escape = (val: string) => `"${(val || '').replace(/"/g, '""')}"`
const rows = users.map(u => [
escape(u.lastName), escape(u.firstName), escape(u.email),
escape(this.formatRole(u.role)), escape(u.program || ''),
escape(u.archived ? 'Archived' : 'Active'),
escape(this.formatDate(u.lastLogin)),
String(this.props.loginCounts.get(u.id) || 0),
escape(this.formatDate(u.lastAction)),
escape(u.lastActionType || '')
].join(','))
Expand All @@ -149,6 +180,8 @@ class AllUsersTable extends React.Component<Props, State> {
</th>
)

const { rangeStart, rangeEnd, onRangeChange } = this.props

return (
<Grid container direction="column" spacing={2}>
<Grid item style={{ display: 'flex', gap: 16, flexWrap: 'wrap', alignItems: 'center' }}>
Expand Down Expand Up @@ -177,6 +210,25 @@ class AllUsersTable extends React.Component<Props, State> {
</Button>
</Grid>

<Grid item style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'center' }}>
<span style={{ fontSize: 14, fontWeight: 500, color: '#555' }}>Login range:</span>
<MuiPickersUtilsProvider utils={DateFnsUtils}>
<KeyboardDatePicker
disableToolbar variant="inline" format="MM/dd/yy" margin="none"
autoOk label="From" value={rangeStart} style={{ width: 150 }}
onChange={(date: Date | null) => date && onRangeChange(date, rangeEnd)}
/>
<KeyboardDatePicker
disableToolbar variant="inline" format="MM/dd/yy" margin="none"
autoOk label="To" value={rangeEnd} style={{ width: 150 }}
onChange={(date: Date | null) => date && onRangeChange(rangeStart, date)}
/>
</MuiPickersUtilsProvider>
<Button size="small" variant="outlined" onClick={() => this.applyPreset(7)}>Last 7d</Button>
<Button size="small" variant="outlined" onClick={() => this.applyPreset(30)}>Last 30d</Button>
<Button size="small" variant="outlined" onClick={() => this.applyPreset(90)}>Last 90d</Button>
</Grid>

<Grid item style={{ overflowX: 'auto' }}>
<table style={{ borderCollapse: 'collapse', width: '100%', minWidth: 800 }}>
<thead style={{ borderBottom: '2px solid #0988ec' }}>
Expand All @@ -188,13 +240,14 @@ class AllUsersTable extends React.Component<Props, State> {
<SortHeader field="program" label="Program" />
<SortHeader field="archived" label="Status" />
<SortHeader field="lastLogin" label="Last Login" />
<SortHeader field="loginCount" label={`Logins (${this.formatRangeLabel()})`} />
<SortHeader field="lastAction" label="Last Action" />
<th style={{ padding: '4px 8px', textAlign: 'center', fontSize: '1.25rem', fontWeight: 500 }}><strong>Edit</strong></th>
</tr>
</thead>
<tbody>
{paginated.length === 0 ? (
<tr><TableCell colSpan={9} style={{ textAlign: 'center', padding: 40 }}>No users found</TableCell></tr>
<tr><TableCell colSpan={10} style={{ textAlign: 'center', padding: 40 }}>No users found</TableCell></tr>
) : paginated.map(user => (
<TableRow key={user.id}>
<TableCell>{user.lastName}</TableCell>
Expand All @@ -214,6 +267,7 @@ class AllUsersTable extends React.Component<Props, State> {
</Tooltip>
</TableCell>
<TableCell>{this.formatDate(user.lastLogin)}</TableCell>
<TableCell>{this.props.loginCounts.get(user.id) || 0}</TableCell>
<TableCell>{this.formatLastAction(user)}</TableCell>
<TableCell onClick={e => e.stopPropagation()} style={{ textAlign: 'center' }}>
<Tooltip title="Edit user">
Expand Down
37 changes: 33 additions & 4 deletions src/views/protected/AdminViews/AllUsersPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ interface Props { isAdmin: boolean }
interface State {
loading: boolean
users: Types.User[]
loginCounts: Map<string, number>
rangeStart: Date
rangeEnd: Date
editOpen: boolean
archiveOpen: boolean
selected: Types.User | null
Expand All @@ -20,21 +23,43 @@ interface State {
email: string
}

const defaultRange = (): { start: Date; end: Date } => {
const end = new Date()
end.setHours(23, 59, 59, 999)
const start = new Date()
start.setDate(start.getDate() - 29)
start.setHours(0, 0, 0, 0)
return { start, end }
}

class AllUsersPage extends React.Component<Props, State> {
static contextType = FirebaseContext
context!: Firebase

state: State = {
loading: true, users: [], editOpen: false, archiveOpen: false,
loading: true, users: [], loginCounts: new Map(),
rangeStart: defaultRange().start, rangeEnd: defaultRange().end,
editOpen: false, archiveOpen: false,
selected: null, firstName: '', lastName: '', email: '',
}

componentDidMount() {
this.context.getAllUsers()
.then(users => this.setState({ users, loading: false }))
const { rangeStart, rangeEnd } = this.state
Promise.all([
this.context.getAllUsers(),
this.context.getUsersLoginCounts(rangeStart, rangeEnd)
])
.then(([users, loginCounts]) => this.setState({ users, loginCounts, loading: false }))
.catch(() => { alert('Error loading users'); this.setState({ loading: false }) })
}

handleRangeChange = (start: Date, end: Date) => {
this.setState({ rangeStart: start, rangeEnd: end })
this.context.getUsersLoginCounts(start, end)
.then(loginCounts => this.setState({ loginCounts }))
.catch(() => alert('Error loading login counts'))
}

handleUserClick = (user: Types.User) => {
this.setState({
selected: user, firstName: user.firstName, lastName: user.lastName,
Expand Down Expand Up @@ -66,7 +91,7 @@ class AllUsersPage extends React.Component<Props, State> {

render() {
const { isAdmin } = this.props
const { loading, users, editOpen, archiveOpen, selected, firstName, lastName, email } = this.state
const { loading, users, loginCounts, rangeStart, rangeEnd, editOpen, archiveOpen, selected, firstName, lastName, email } = this.state

return (
<div style={{ height: '100vh', overflow: 'auto' }}>
Expand All @@ -86,6 +111,10 @@ class AllUsersPage extends React.Component<Props, State> {
) : (
<AllUsersTable
users={users}
loginCounts={loginCounts}
rangeStart={rangeStart}
rangeEnd={rangeEnd}
onRangeChange={this.handleRangeChange}
onUserClick={this.handleUserClick}
onArchiveClick={user => this.setState({ selected: user, archiveOpen: true })}
/>
Expand Down