diff --git a/cmd/login.go b/cmd/login.go index 47598f62..c6175b4e 100644 --- a/cmd/login.go +++ b/cmd/login.go @@ -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) } diff --git a/frontend/src/features/admin/users/UserForm.vue b/frontend/src/features/admin/users/UserForm.vue index 360afe63..7fe6c807 100644 --- a/frontend/src/features/admin/users/UserForm.vue +++ b/frontend/src/features/admin/users/UserForm.vue @@ -1,5 +1,59 @@ @@ -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) => { diff --git a/i18n/en.json b/i18n/en.json index 74a83716..e4a17daa 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -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", diff --git a/i18n/mr.json b/i18n/mr.json index 5f75d444..8e9fb3f2 100644 --- a/i18n/mr.json +++ b/i18n/mr.json @@ -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", diff --git a/internal/migrations/v0.6.0.go b/internal/migrations/v0.6.0.go index d0ecd8a7..8a259b2c 100644 --- a/internal/migrations/v0.6.0.go +++ b/internal/migrations/v0.6.0.go @@ -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 } diff --git a/internal/user/models/models.go b/internal/user/models/models.go index 5f46f43a..4a22b1e8 100644 --- a/internal/user/models/models.go +++ b/internal/user/models/models.go @@ -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"` diff --git a/internal/user/queries.sql b/internal/user/queries.sql index 31d7ef6d..91c88415 100644 --- a/internal/user/queries.sql +++ b/internal/user/queries.sql @@ -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; \ No newline at end of file diff --git a/internal/user/user.go b/internal/user/user.go index 90648530..30c866f3 100644 --- a/internal/user/user.go +++ b/internal/user/user.go @@ -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. diff --git a/schema.sql b/schema.sql index 673107f1..a565c65b 100644 --- a/schema.sql +++ b/schema.sql @@ -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),