mirror of
https://github.com/abhinavxd/libredesk.git
synced 2026-09-10 22:25:39 +00:00
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:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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;
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user