feat: show user summary on /admin/users

- Displays metrics like last active at, last login along with the avatar.
- Adds new column `last_login_at` to users table.
- Updates translations for the same
This commit is contained in:
Abhinav Raut
2025-04-04 22:00:26 +05:30
parent 2a382d6036
commit 6d588f7a4e
9 changed files with 131 additions and 1 deletions
+6
View File
@@ -47,6 +47,12 @@ func handleLogin(r *fastglue.Request) error {
app.lo.Error("error setting csrf cookie", "error", err)
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorSaving", "name", "{globals.terms.session}"), nil))
}
// Update last login time.
if err := app.user.UpdateLastLoginAt(user.ID); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(user)
}
+90 -1
View File
@@ -1,5 +1,59 @@
<template>
<form @submit.prevent="onSubmit" class="space-y-6">
<form @submit.prevent="onSubmit" class="space-y-8">
<!-- Summary Section -->
<div class="bg-muted/30 box py-6 px-3">
<div class="flex items-start gap-6">
<Avatar class="w-20 h-20">
<AvatarImage :src="props.initialValues.avatar_url" :alt="Avatar" />
<AvatarFallback>
{{ getInitials(props.initialValues.first_name, props.initialValues.last_name) }}
</AvatarFallback>
</Avatar>
<div class="space-y-4 flex-2">
<div class="flex items-center gap-3">
<h3 class="text-lg font-semibold text-gray-900">
{{ props.initialValues.first_name }} {{ props.initialValues.last_name }}
</h3>
<Badge :class="['p-1 rounded-full text-xs font-medium', availabilityStatus.color]">
{{ availabilityStatus.text }}
</Badge>
</div>
<div class="flex flex-wrap items-center gap-6">
<div class="flex items-center gap-2">
<Clock class="w-5 h-5 text-gray-400" />
<div>
<p class="text-sm text-gray-500">{{ $t('form.field.lastActive') }}</p>
<p class="text-sm font-medium text-gray-700">
{{
props.initialValues.last_active_at
? format(new Date(props.initialValues.last_active_at), 'PPpp')
: 'N/A'
}}
</p>
</div>
</div>
<div class="flex items-center gap-2">
<LogIn class="w-5 h-5 text-gray-400" />
<div>
<p class="text-sm text-gray-500"> {{ $t('form.field.lastLogin') }}</p>
<p class="text-sm font-medium text-gray-700">
{{
props.initialValues.last_login_at
? format(new Date(props.initialValues.last_login_at), 'PPpp')
: 'N/A'
}}
</p>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Form Fields -->
<FormField v-slot="{ field }" name="first_name">
<FormItem v-auto-animate>
<FormLabel>{{ $t('form.field.firstName') }}</FormLabel>
@@ -94,6 +148,23 @@
</FormItem>
</FormField>
<FormField
v-slot="{ value, handleChange }"
type="checkbox"
name="reassign_replies"
v-if="!isNewForm"
>
<FormItem class="flex flex-row items-start gap-x-3 space-y-0">
<FormControl>
<Checkbox :checked="value" @update:checked="handleChange" />
</FormControl>
<div class="space-y-1 leading-none">
<FormLabel> {{ $t('navigation.reassign_replies') }} </FormLabel>
<FormMessage />
</div>
</FormItem>
</FormField>
<Button type="submit" :isLoading="isLoading"> {{ submitLabel }} </Button>
</form>
</template>
@@ -107,10 +178,14 @@ import { createFormSchema } from './formSchema.js'
import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label'
import { vAutoAnimate } from '@formkit/auto-animate/vue'
import { Badge } from '@/components/ui/badge'
import { Clock, LogIn } from 'lucide-vue-next'
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { SelectTag } from '@/components/ui/select'
import { Input } from '@/components/ui/input'
import { useI18n } from 'vue-i18n'
import { format } from 'date-fns'
import api from '@/api'
const props = defineProps({
@@ -151,6 +226,13 @@ onMounted(async () => {
}
})
const availabilityStatus = computed(() => {
const status = form.values.availability_status
if (status === 'online') return { text: 'Online', color: 'bg-green-500' }
if (status === 'away' || status === 'away_manual') return { text: 'Away', color: 'bg-yellow-500' }
return { text: 'Offline', color: 'bg-gray-400' }
})
const teamOptions = computed(() =>
teams.value.map((team) => ({ label: team.name, value: team.name }))
)
@@ -167,6 +249,13 @@ const onSubmit = form.handleSubmit((values) => {
props.submitForm(values)
})
const getInitials = (firstName, lastName) => {
if (!firstName && !lastName) return ''
if (!firstName) return lastName.charAt(0).toUpperCase()
if (!lastName) return firstName.charAt(0).toUpperCase()
return `${firstName.charAt(0).toUpperCase()}${lastName.charAt(0).toUpperCase()}`
}
watch(
() => props.initialValues,
(newValues) => {
+3
View File
@@ -238,6 +238,9 @@
"navigation.delete": "Delete",
"navigation.reassign_replies": "Reassign replies",
"form.field.name": "Name",
"form.field.availabilityStatus": "Availability Status",
"form.field.lastActive": "Last active",
"form.field.lastLogin": "Last login",
"form.field.inbox": "Inbox",
"form.field.provider": "Provider",
"form.field.providerURL": "Provider URL",
+3
View File
@@ -238,6 +238,9 @@
"navigation.delete": "हटवा",
"navigation.reassign_replies": "प्रतिसाद पुन्हा नियुक्त करा",
"form.field.name": "नाव",
"form.field.availabilityStatus": "उपलब्धता स्थिती",
"form.field.lastActive": "शेवटचे सक्रिय",
"form.field.lastLogin": "शेवटचे लॉगिन",
"form.field.inbox": "इनबॉक्स",
"form.field.provider": "प्रदाता",
"form.field.providerURL": "प्रदाता URL",
+7
View File
@@ -14,5 +14,12 @@ func V0_6_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error {
if err != nil {
return err
}
_, err = db.Exec(`
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login_at TIMESTAMPTZ NULL;
`)
if err != nil {
return err
}
return nil
}
+2
View File
@@ -35,6 +35,8 @@ type User struct {
AvatarURL null.String `db:"avatar_url" json:"avatar_url"`
Enabled bool `db:"enabled" json:"enabled"`
Password string `db:"password" json:"-"`
LastActiveAt null.Time `db:"last_active_at" json:"last_active_at"`
LastLoginAt null.Time `db:"last_login_at" json:"last_login_at"`
ReassignReplies bool `db:"reassign_replies" json:"reassign_replies"`
Roles pq.StringArray `db:"roles" json:"roles"`
Permissions pq.StringArray `db:"permissions" json:"permissions"`
+9
View File
@@ -24,6 +24,7 @@ SELECT
u.id,
u.email,
u.password,
u.type,
u.created_at,
u.updated_at,
u.enabled,
@@ -32,6 +33,8 @@ SELECT
u.last_name,
u.availability_status,
u.reassign_replies,
u.last_active_at,
u.last_login_at,
array_agg(DISTINCT r.name) as roles,
COALESCE(
(SELECT json_agg(json_build_object('id', t.id, 'name', t.name, 'emoji', t.emoji))
@@ -140,4 +143,10 @@ RETURNING contact_id, id;
-- name: set-reassign-replies
UPDATE users
SET reassign_replies = $2
WHERE id = $1;
-- name: update-last-login-at
UPDATE users
SET last_login_at = now(),
updated_at = now()
WHERE id = $1;
+10
View File
@@ -64,6 +64,7 @@ type queries struct {
UpdateAvailability *sqlx.Stmt `query:"update-availability"`
UpdateLastActiveAt *sqlx.Stmt `query:"update-last-active-at"`
UpdateInactiveOffline *sqlx.Stmt `query:"update-inactive-offline"`
UpdateLastLoginAt *sqlx.Stmt `query:"update-last-login-at"`
SoftDeleteUser *sqlx.Stmt `query:"soft-delete-user"`
SetUserPassword *sqlx.Stmt `query:"set-user-password"`
SetResetPasswordToken *sqlx.Stmt `query:"set-reset-password-token"`
@@ -230,6 +231,15 @@ func (u *Manager) Update(id int, user models.User) error {
return nil
}
// UpdateLastLoginAt updates the last login timestamp of an user.
func (u *Manager) UpdateLastLoginAt(id int) error {
if _, err := u.q.UpdateLastLoginAt.Exec(id); err != nil {
u.lo.Error("error updating user last login at", "error", err)
return envelope.NewError(envelope.GeneralError, u.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.user}"), nil)
}
return nil
}
// SoftDelete soft deletes an user.
func (u *Manager) SoftDelete(id int) error {
// Disallow if user is system user.
+1
View File
@@ -125,6 +125,7 @@ CREATE TABLE users (
reset_password_token_expiry TIMESTAMPTZ NULL,
availability_status user_availability_status DEFAULT 'offline' NOT NULL,
last_active_at TIMESTAMPTZ NULL,
last_login_at TIMESTAMPTZ NULL,
reassign_replies BOOL DEFAULT FALSE NOT NULL,
CONSTRAINT constraint_users_on_country CHECK (LENGTH(country) <= 140),
CONSTRAINT constraint_users_on_phone_number CHECK (LENGTH(phone_number) <= 20),