diff --git a/drizzle/0001_many_weapon_omega.sql b/drizzle/0001_many_weapon_omega.sql new file mode 100644 index 0000000..b34e609 --- /dev/null +++ b/drizzle/0001_many_weapon_omega.sql @@ -0,0 +1,14 @@ +CREATE TABLE "login_history" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" text NOT NULL, + "application_id" uuid, + "session_id" text, + "logged_at" timestamp with time zone DEFAULT now() NOT NULL, + "ip_address" text, + "user_agent" text +); +--> statement-breakpoint +ALTER TABLE "user" ADD COLUMN "last_login_at" timestamp;--> statement-breakpoint +ALTER TABLE "login_history" ADD CONSTRAINT "login_history_application_id_applications_id_fk" FOREIGN KEY ("application_id") REFERENCES "public"."applications"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "login_history_user_logged_idx" ON "login_history" USING btree ("user_id","logged_at");--> statement-breakpoint +CREATE INDEX "login_history_app_logged_idx" ON "login_history" USING btree ("application_id","logged_at"); \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index a883156..88ca20f 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1776085749779, "tag": "0000_initial_schema", "breakpoints": false + }, + { + "idx": 1, + "version": "7", + "when": 1778972820706, + "tag": "0001_many_weapon_omega", + "breakpoints": true } ] } \ No newline at end of file diff --git a/frontend/src/api/applications.ts b/frontend/src/api/applications.ts index fea33d4..6157336 100644 --- a/frontend/src/api/applications.ts +++ b/frontend/src/api/applications.ts @@ -1,5 +1,5 @@ import { apiFetch, USE_MOCK } from './client'; -import type { Application, ApplicationCreateResponse, UserApplication, AppRole, AppPermission, SubscriptionPlan, SubscriptionPlanPrice } from '@/types'; +import type { Application, ApplicationCreateResponse, UserApplication, AppRole, AppPermission, SubscriptionPlan, SubscriptionPlanPrice, LoginHistoryResponse } from '@/types'; import { MOCK_APPLICATIONS, MOCK_ROLES, MOCK_PERMISSIONS, MOCK_PLANS, MOCK_USER_APPLICATIONS } from '@/mocks/data'; export async function listApplications(): Promise<{ applications: Application[] }> { @@ -101,6 +101,16 @@ export async function listAppUsers(appId: string): Promise<{ users: UserApplicat return apiFetch<{ users: UserApplication[] }>(`/admin/applications/${appId}/users`); } +export async function getUserLoginHistory(appId: string, userId: string, params: { page?: number; limit?: number } = {}): Promise { + if (USE_MOCK) { + return { entries: [], total: 0, page: params.page ?? 1, limit: params.limit ?? 20 }; + } + const qs = new URLSearchParams(); + if (params.page) qs.set('page', String(params.page)); + if (params.limit) qs.set('limit', String(params.limit)); + return apiFetch(`/admin/applications/${appId}/users/${userId}/history?${qs}`); +} + export async function grantAppAccess(appId: string, body: { userId: string; roleId?: string }): Promise<{ access: UserApplication }> { if (USE_MOCK) { const access: UserApplication = { userId: body.userId, applicationId: appId, isActive: true, subscriptionPlanId: null, createdAt: new Date().toISOString(), name: null, email: null, roleId: body.roleId ?? null }; diff --git a/frontend/src/api/sessions.ts b/frontend/src/api/sessions.ts index 78d4675..cb6c24a 100644 --- a/frontend/src/api/sessions.ts +++ b/frontend/src/api/sessions.ts @@ -9,16 +9,26 @@ export interface SessionsListResponse { limit: number } -export async function listSessions(params: { page?: number; limit?: number } = {}): Promise { +export async function listSessions(params: { page?: number; limit?: number; search?: string } = {}): Promise { if (USE_MOCK) { const page = params.page ?? 1; const limit = params.limit ?? 20; + const search = params.search?.trim().toLowerCase() ?? ''; + let pool = MOCK_SESSIONS; + if (search) { + pool = pool.filter(s => + (s.user?.name ?? '').toLowerCase().includes(search) + || (s.user?.email ?? '').toLowerCase().includes(search) + || (s.ipAddress ?? '').toLowerCase().includes(search), + ); + } const start = (page - 1) * limit; - return { sessions: MOCK_SESSIONS.slice(start, start + limit), total: MOCK_SESSIONS.length, page, limit }; + return { sessions: pool.slice(start, start + limit), total: pool.length, page, limit }; } const qs = new URLSearchParams(); if (params.page) qs.set('page', String(params.page)); if (params.limit) qs.set('limit', String(params.limit)); + if (params.search) qs.set('search', params.search); return apiFetch(`/admin/sessions?${qs}`); } diff --git a/frontend/src/api/stats.ts b/frontend/src/api/stats.ts new file mode 100644 index 0000000..976cda9 --- /dev/null +++ b/frontend/src/api/stats.ts @@ -0,0 +1,65 @@ +import { apiFetch, USE_MOCK } from './client'; + +export interface ActiveUsersResponse { + count: number; +} + +export interface LoginsSeriesPoint { + date: string; + count: number; +} + +export interface LoginsResponse { + range: '7d' | '30d'; + series: LoginsSeriesPoint[]; + total: number; +} + +export interface AppActivityEntry { + appId: string; + online: number; + last7dLogins: number; + sparkline: number[]; +} + +export interface ApplicationsActivityResponse { + applications: AppActivityEntry[]; +} + +function mockSeries(days: number): LoginsSeriesPoint[] { + const out: LoginsSeriesPoint[] = []; + const now = new Date(); + for (let i = days - 1; i >= 0; i--) { + const d = new Date(now); + d.setUTCDate(d.getUTCDate() - i); + const date = d.toISOString().slice(0, 10); + out.push({ date, count: Math.floor(Math.random() * 8) }); + } + return out; +} + +export async function getActiveUsers(): Promise { + if (USE_MOCK) return { count: Math.floor(Math.random() * 12) }; + return apiFetch('/admin/stats/active-users'); +} + +export async function getLogins(params: { range?: '7d' | '30d'; appId?: string } = {}): Promise { + const range = params.range ?? '7d'; + if (USE_MOCK) { + const series = mockSeries(range === '7d' ? 7 : 30); + return { range, series, total: series.reduce((a, b) => a + b.count, 0) }; + } + const qs = new URLSearchParams(); + qs.set('range', range); + if (params.appId) qs.set('appId', params.appId); + return apiFetch(`/admin/stats/logins?${qs}`); +} + +export async function getApplicationsActivity(): Promise { + if (USE_MOCK) { + return { + applications: [], + }; + } + return apiFetch('/admin/stats/applications-activity'); +} diff --git a/frontend/src/api/users.ts b/frontend/src/api/users.ts index f0aed63..44c32fa 100644 --- a/frontend/src/api/users.ts +++ b/frontend/src/api/users.ts @@ -60,7 +60,7 @@ export async function getUser(id: string): Promise { if (!user) throw new Error('User not found'); const apps: UserApplicationDetail[] = MOCK_USER_APPLICATIONS .filter(a => a.userId === id) - .map(a => ({ id: a.applicationId, name: a.name ?? a.applicationId, slug: a.applicationId, icon: null, isActive: a.isActive, subscriptionPlanId: a.subscriptionPlanId, roles: a.roleId ? [{ id: a.roleId, name: a.roleId }] : [] })); + .map(a => ({ id: a.applicationId, name: a.name ?? a.applicationId, slug: a.applicationId, icon: null, isActive: a.isActive, subscriptionPlanId: a.subscriptionPlanId, subscriptionPlanName: a.subscriptionPlanName ?? null, roles: a.roleId ? [{ id: a.roleId, name: a.roleId }] : [], lastLoginAt: a.lastLoginAt ?? null })); return { user, applications: apps }; } return apiFetch(`/admin/users/${id}`); diff --git a/frontend/src/components/ui/AppIconStack.vue b/frontend/src/components/ui/AppIconStack.vue new file mode 100644 index 0000000..daacb16 --- /dev/null +++ b/frontend/src/components/ui/AppIconStack.vue @@ -0,0 +1,126 @@ + + + diff --git a/frontend/src/components/ui/DataTable.vue b/frontend/src/components/ui/DataTable.vue index b7fc0d9..f59b46e 100644 --- a/frontend/src/components/ui/DataTable.vue +++ b/frontend/src/components/ui/DataTable.vue @@ -1,44 +1,406 @@ - diff --git a/frontend/src/components/ui/Sparkline.vue b/frontend/src/components/ui/Sparkline.vue new file mode 100644 index 0000000..c5e7aff --- /dev/null +++ b/frontend/src/components/ui/Sparkline.vue @@ -0,0 +1,63 @@ + + + diff --git a/frontend/src/components/users/UserAppHistoryModal.vue b/frontend/src/components/users/UserAppHistoryModal.vue new file mode 100644 index 0000000..78997b2 --- /dev/null +++ b/frontend/src/components/users/UserAppHistoryModal.vue @@ -0,0 +1,95 @@ + + + diff --git a/frontend/src/i18n/en.ts b/frontend/src/i18n/en.ts index e212769..c31d220 100644 --- a/frontend/src/i18n/en.ts +++ b/frontend/src/i18n/en.ts @@ -72,6 +72,7 @@ export default { newRegistrations: 'New Registrations', sessionActivity: 'Session Activity', activeSessionsList: 'Active Sessions', + allApplications: 'All applications', platformAlerts: 'Platform Alerts', quickActions: 'Quick Actions', createUser: 'Create User', @@ -82,6 +83,10 @@ export default { noMfaEnforced: 'MFA is not enforced platform-wide. Consider requiring it for sensitive applications.', lastRefreshed: 'Last refreshed', revoke: 'Revoke', + onlineNow: 'Online Now', + onlineNowSub: 'Active in the last 5 min', + loginsChart: 'Login Activity', + totalLogins: '{count} logins', timeRanges: { '7d': 'Last 7 days', '30d': 'Last 30 days', @@ -124,6 +129,39 @@ export default { isMfaRequired: 'Force MFA', applications: 'Applications', password: 'Password', + lastLogin: 'Last login', + never: 'Never', + columns: { + name: 'Name', + role: 'Role', + verified: 'Verified', + mfa: '2FA', + applications: 'Applications', + lastLogin: 'Last login', + ipAddress: 'IP', + device: 'Device', + history: 'History', + createdAt: 'Created', + actions: 'Actions', + }, + history: { + title: 'Login history', + description: 'Each entry represents an OAuth token issuance for this user on this application.', + empty: 'No logins recorded yet.', + open: 'View login history', + }, + }, + dataTable: { + sortAsc: 'Sort ascending', + sortDesc: 'Sort descending', + clearSort: 'Clear sort', + columns: 'Columns', + density: 'Density', + search: 'Search', + previous: 'Previous page', + next: 'Next page', + pageOf: '{from}–{to} of {total}', + rowsPerPage: 'Rows per page', }, organizations: { title: 'Organizations', @@ -164,6 +202,11 @@ export default { title: 'Applications', subtitle: 'Manage OAuth2 applications', createApp: 'Create Application', + activity: { + online: 'Online', + last7d: '7-day logins', + sparklineEmpty: 'No recent activity', + }, name: 'Name', slug: 'Slug (Client ID)', description: 'Description', diff --git a/frontend/src/i18n/fr.ts b/frontend/src/i18n/fr.ts index 5e98ab0..a972c34 100644 --- a/frontend/src/i18n/fr.ts +++ b/frontend/src/i18n/fr.ts @@ -72,6 +72,7 @@ export default { newRegistrations: 'Nouvelles inscriptions', sessionActivity: 'Activité des sessions', activeSessionsList: 'Sessions actives', + allApplications: 'Toutes les applications', platformAlerts: 'Alertes plateforme', quickActions: 'Actions rapides', createUser: 'Créer un utilisateur', @@ -82,6 +83,10 @@ export default { noMfaEnforced: 'L\'authentification MFA n\'est pas imposée à l\'échelle de la plateforme.', lastRefreshed: 'Dernière actualisation', revoke: 'Révoquer', + onlineNow: 'En ligne', + onlineNowSub: 'Actifs dans les 5 dernières minutes', + loginsChart: 'Activité des connexions', + totalLogins: '{count} connexions', timeRanges: { '7d': '7 derniers jours', '30d': '30 derniers jours', @@ -124,6 +129,39 @@ export default { isMfaRequired: 'Forcer le MFA', applications: 'Applications', password: 'Mot de passe', + lastLogin: 'Dernière connexion', + never: 'Jamais', + columns: { + name: 'Nom', + role: 'Rôle', + verified: 'Vérifié', + mfa: '2FA', + applications: 'Applications', + lastLogin: 'Dernière connexion', + ipAddress: 'IP', + device: 'Appareil', + history: 'Historique', + createdAt: 'Créé le', + actions: 'Actions', + }, + history: { + title: 'Historique de connexion', + description: 'Chaque entrée correspond à l\'émission d\'un jeton OAuth pour cet utilisateur sur cette application.', + empty: 'Aucune connexion enregistrée.', + open: 'Voir l\'historique de connexion', + }, + }, + dataTable: { + sortAsc: 'Tri croissant', + sortDesc: 'Tri décroissant', + clearSort: 'Réinitialiser le tri', + columns: 'Colonnes', + density: 'Densité', + search: 'Rechercher', + previous: 'Page précédente', + next: 'Page suivante', + pageOf: '{from}–{to} sur {total}', + rowsPerPage: 'Lignes par page', }, organizations: { title: 'Organisations', @@ -164,6 +202,11 @@ export default { title: 'Applications', subtitle: 'Gérer les applications OAuth2', createApp: 'Créer une application', + activity: { + online: 'En ligne', + last7d: 'Connexions 7 j', + sparklineEmpty: 'Aucune activité récente', + }, name: 'Nom', slug: 'Identifiant (Client ID)', description: 'Description', diff --git a/frontend/src/mocks/data.ts b/frontend/src/mocks/data.ts index 82a9e37..488f195 100644 --- a/frontend/src/mocks/data.ts +++ b/frontend/src/mocks/data.ts @@ -23,6 +23,11 @@ export const MOCK_USERS: User[] = [ company: 'CIRCLE', position: 'Platform Engineer', address: '42 rue de la Paix, 75001 Paris', + lastLoginAt: '2024-11-15T09:23:11.000Z', + applications: [ + { id: '550e8400-e29b-41d4-a716-446655440000', name: 'Circle Dashboard', slug: 'circle-dashboard', icon: null }, + { id: '6ba7b811-9dad-11d1-80b4-00c04fd430c8', name: 'Internal API', slug: 'internal-api', icon: null }, + ], }, { id: 'usr_5nRmW9tGxKqPsUvCeL7zBi', @@ -42,6 +47,10 @@ export const MOCK_USERS: User[] = [ company: 'Acme Corp', position: 'CTO', address: null, + lastLoginAt: '2024-11-14T08:21:00.000Z', + applications: [ + { id: '6ba7b810-9dad-11d1-80b4-00c04fd430c8', name: 'Acme App', slug: 'acme-app', icon: 'https://acme.com/favicon.ico' }, + ], }, { id: 'usr_8jHkD4aNcOeQyMfTgX2rVo', @@ -61,6 +70,8 @@ export const MOCK_USERS: User[] = [ company: null, position: null, address: null, + lastLoginAt: '2024-10-22T12:08:00.000Z', + applications: [], }, { id: 'usr_1cEwZ6sPiLnXbQdRkA9mFu', @@ -80,6 +91,10 @@ export const MOCK_USERS: User[] = [ company: 'Startup XYZ', position: 'Developer', address: null, + lastLoginAt: null, + applications: [ + { id: '6ba7b810-9dad-11d1-80b4-00c04fd430c8', name: 'Acme App', slug: 'acme-app', icon: 'https://acme.com/favicon.ico' }, + ], }, ]; @@ -95,6 +110,10 @@ export const MOCK_SESSIONS: Session[] = [ userId: 'usr_2wQx8mNpKvLrTbYcJdF3hA', impersonatedBy: null, activeOrganizationId: null, + user: { id: 'usr_2wQx8mNpKvLrTbYcJdF3hA', name: 'Alexandre Dubois', email: 'alexandre.dubois@example.com', image: null }, + applications: [ + { id: '550e8400-e29b-41d4-a716-446655440000', name: 'Circle Dashboard', slug: 'circle-dashboard', icon: null }, + ], }, { id: 'sess_6uZaS1fPkOeVdNbQlIrCg4', @@ -107,6 +126,10 @@ export const MOCK_SESSIONS: Session[] = [ userId: 'usr_5nRmW9tGxKqPsUvCeL7zBi', impersonatedBy: null, activeOrganizationId: 'org_7vBmC4dKpLqRtNsWoXeA', + user: { id: 'usr_5nRmW9tGxKqPsUvCeL7zBi', name: 'Sofia Marchetti', email: 'sofia.marchetti@example.com', image: 'https://api.dicebear.com/9.x/initials/svg?seed=SM' }, + applications: [ + { id: '6ba7b810-9dad-11d1-80b4-00c04fd430c8', name: 'Acme App', slug: 'acme-app', icon: 'https://acme.com/favicon.ico' }, + ], }, ]; @@ -284,8 +307,8 @@ export const MOCK_ORGANIZATIONS: Organization[] = [ ]; export const MOCK_USER_APPLICATIONS: UserApplication[] = [ - { userId: 'usr_5nRmW9tGxKqPsUvCeL7zBi', applicationId: '6ba7b810-9dad-11d1-80b4-00c04fd430c8', isActive: true, subscriptionPlanId: 'plan-0001-0000-0000-000000000002', createdAt: '2024-03-25T10:00:00.000Z', name: 'Sofia Marchetti', email: 'sofia.marchetti@example.com', roleId: 'a1b2c3d4-0001-0000-0000-000000000002' }, - { userId: 'usr_1cEwZ6sPiLnXbQdRkA9mFu', applicationId: '6ba7b810-9dad-11d1-80b4-00c04fd430c8', isActive: true, subscriptionPlanId: 'plan-0001-0000-0000-000000000001', createdAt: '2024-09-05T08:00:00.000Z', name: 'Margot Lefèvre', email: 'margot.lefevre@example.com', roleId: 'a1b2c3d4-0001-0000-0000-000000000001' }, + { userId: 'usr_5nRmW9tGxKqPsUvCeL7zBi', applicationId: '6ba7b810-9dad-11d1-80b4-00c04fd430c8', isActive: true, subscriptionPlanId: 'plan-0001-0000-0000-000000000002', subscriptionPlanName: 'Pro', createdAt: '2024-03-25T10:00:00.000Z', name: 'Sofia Marchetti', email: 'sofia.marchetti@example.com', roleId: 'a1b2c3d4-0001-0000-0000-000000000002', lastLoginAt: '2024-11-14T08:21:00.000Z', lastIp: '91.198.174.192', lastUserAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Firefox/132.0' }, + { userId: 'usr_1cEwZ6sPiLnXbQdRkA9mFu', applicationId: '6ba7b810-9dad-11d1-80b4-00c04fd430c8', isActive: true, subscriptionPlanId: 'plan-0001-0000-0000-000000000001', subscriptionPlanName: 'Free', createdAt: '2024-09-05T08:00:00.000Z', name: 'Margot Lefèvre', email: 'margot.lefevre@example.com', roleId: 'a1b2c3d4-0001-0000-0000-000000000001', lastLoginAt: null, lastIp: null, lastUserAgent: null }, ]; export const MOCK_CONSUMPTION: ConsumptionAggregate[] = [ diff --git a/frontend/src/types/data-table.ts b/frontend/src/types/data-table.ts new file mode 100644 index 0000000..1e18f29 --- /dev/null +++ b/frontend/src/types/data-table.ts @@ -0,0 +1,53 @@ +import type { Component } from 'vue'; + +/** + * Column definition for the shared DataTable component. + * + * - `key`: stable identifier; matches the named slot `cell-` if a + * custom cell renderer is provided. + * - `field`: dot-path accessor on the row object used for default text + * rendering and client-side sort. Optional when `cell-` slot is used. + * - `responsive`: Tailwind responsive prefix that controls visibility — the + * column is hidden below the breakpoint (e.g. `md` hides on mobile). + * - `align`: cell alignment; default `left`. + * - `sortable`: enables the sort caret in the header. + * - `width`: optional fixed width class (e.g. `w-32`, `w-[180px]`). + * - `hidden`: column-visibility toggle; managed by the table when the + * visibility dropdown is rendered, or controlled externally. + */ +export interface ColumnDef> { + key: string; + label: string; + field?: string; + responsive?: 'sm' | 'md' | 'lg' | 'xl' | '2xl'; + align?: 'left' | 'right' | 'center'; + sortable?: boolean; + width?: string; + hidden?: boolean; + icon?: Component; + /** + * Optional value extractor used by client-side sort and default text + * rendering when no `cell-` slot is provided. Receives the full row. + */ + accessor?: (row: Row) => unknown; +} + +export type SortDirection = 'asc' | 'desc'; + +export interface SortState { + key: string; + direction: SortDirection; +} + +/** + * Server-driven pagination state shared between the parent view and the + * DataTable toolbar. `total` is the unfiltered count of rows; `page` is + * 1-indexed; `limit` is the page size. + */ +export interface DataTablePagination { + page: number; + limit: number; + total: number; + /** Optional explicit choices shown in the rows-per-page selector. */ + pageSizes?: number[]; +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 639b0ec..909c689 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -16,6 +16,17 @@ export interface User { company: string | null position: string | null address: string | null + /** ISO timestamp of the most recent OAuth token issuance. Null when the user has never logged in. */ + lastLoginAt?: string | null + /** Applications the user currently has active access to. Populated by /admin/users. */ + applications?: UserAppSummary[] +} + +export interface UserAppSummary { + id: string + name: string + slug: string + icon: string | null } export interface Session { @@ -29,6 +40,10 @@ export interface Session { userId: string impersonatedBy: string | null activeOrganizationId: string | null + /** Enriched user identity (populated by /admin/sessions). */ + user?: { id: string; name: string | null; email: string | null; image: string | null } + /** Applications this user has logged into during the session lifespan. */ + applications?: UserAppSummary[] } export interface MfaSetupResult { @@ -143,10 +158,33 @@ export interface UserApplication { applicationId: string isActive: boolean subscriptionPlanId: string | null + subscriptionPlanName?: string | null createdAt: string name: string | null email: string | null + image?: string | null roleId: string | null + /** Most recent login of this user to this application. */ + lastLoginAt?: string | null + /** IP captured at the most recent login (this app). */ + lastIp?: string | null + /** User-agent captured at the most recent login (this app). */ + lastUserAgent?: string | null +} + +export interface LoginHistoryEntry { + id: string + loggedAt: string + ipAddress: string | null + userAgent: string | null + sessionId: string | null +} + +export interface LoginHistoryResponse { + entries: LoginHistoryEntry[] + total: number + page: number + limit: number } export interface UserApplicationDetail { @@ -156,7 +194,10 @@ export interface UserApplicationDetail { icon: string | null isActive: boolean subscriptionPlanId: string | null + subscriptionPlanName?: string | null roles: { id: string; name: string }[] + /** Most recent login of the user to this application, or null when none. */ + lastLoginAt?: string | null } export interface UserSubscription { diff --git a/frontend/src/views/ApplicationDetailView.vue b/frontend/src/views/ApplicationDetailView.vue index 1ffc08e..d392144 100644 --- a/frontend/src/views/ApplicationDetailView.vue +++ b/frontend/src/views/ApplicationDetailView.vue @@ -20,8 +20,14 @@ import ConfirmDialog from '@/components/ui/ConfirmDialog.vue'; import BaseBadge from '@/components/ui/BaseBadge.vue'; import CopyField from '@/components/ui/CopyField.vue'; import UserAvatar from '@/components/ui/UserAvatar.vue'; +import DataTable from '@/components/ui/DataTable.vue'; +import UserAppHistoryModal from '@/components/users/UserAppHistoryModal.vue'; +import { parseUserAgent } from '@/composables/useUserAgent'; +import Sparkline from '@/components/ui/Sparkline.vue'; +import { getApplicationsActivity, getLogins, type AppActivityEntry } from '@/api/stats'; import type { PlanFeature } from '@/types'; -import { ArrowLeft, Plus, Trash2, RefreshCw, Check, X, AlertTriangle, Code, TrendingUp } from 'lucide-vue-next'; +import type { ColumnDef } from '@/types/data-table'; +import { ArrowLeft, Plus, Trash2, RefreshCw, Check, X, AlertTriangle, Code, TrendingUp, History, Activity } from 'lucide-vue-next'; const { t } = useI18n(); const route = useRoute(); @@ -73,6 +79,7 @@ const planFeatureEntries = ref { await services.fetch(); await loadAll(); + void loadAppActivity(); }); async function loadAll() { @@ -107,6 +114,60 @@ async function loadConsumption() { consumption.value = allAggs; } +// — User-history modal state +const historyModalOpen = ref(false); +const historyModalUserId = ref(null); +const historyModalUserName = ref(''); +function openHistory(ua: UserApplication) { + historyModalUserId.value = ua.userId; + historyModalUserName.value = getUserName(ua.userId); + historyModalOpen.value = true; +} + +// — Activity strip (online + sparkline + 7d logins) +const appActivity = ref(null); +async function loadAppActivity() { + try { + const [activityRes, loginsRes] = await Promise.all([ + getApplicationsActivity(), + getLogins({ range: '7d', appId }), + ]); + const entry = activityRes.applications.find(a => a.appId === appId); + if (entry) { + appActivity.value = entry; + } else { + // Fallback: synthesise a sparkline from the per-app logins endpoint. + appActivity.value = { + appId, + online: 0, + last7dLogins: loginsRes.total, + sparkline: loginsRes.series.map(p => p.count), + }; + } + } catch { + // Silent — activity strip is non-critical. + } +} + +const userColumns = computed[]>(() => [ + { key: 'user', label: t('users.columns.name') }, + { key: 'role', label: t('users.columns.role'), responsive: 'sm' }, + { key: 'plan', label: 'Plan', responsive: 'sm' }, + { key: 'status', label: 'Status', responsive: 'md' }, + { key: 'lastLogin', label: t('users.columns.lastLogin'), field: 'lastLoginAt', sortable: true, responsive: 'md' }, + { key: 'ipAddress', label: t('users.columns.ipAddress'), field: 'lastIp', responsive: 'lg' }, + { key: 'ua', label: t('users.columns.device'), responsive: 'lg' }, + { key: 'history', label: t('users.columns.history'), align: 'right' }, + { key: 'actions', label: t('users.columns.actions'), align: 'right' }, +]); + +// Format with date + time (per spec) so admins can see at a glance the +// exact moment of the last successful login on this application. +function formatLastLogin(iso: string | null | undefined): string { + if (!iso) return t('users.never'); + return new Date(iso).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }); +} + async function handleCreateRole() { formLoading.value = true; try { @@ -585,6 +646,26 @@ const financialKpis = computed(() => { +
+
+
+ + {{ appActivity.online }} + {{ t('applications.activity.online') }} +
+
+
+ + {{ appActivity.last7dLogins }} + {{ t('applications.activity.last7d') }} +
+
+ +
+