From 0001fe8748121df77996b165dac5b67694c864cd Mon Sep 17 00:00:00 2001 From: x42en <0x42en@gmail.com> Date: Sun, 17 May 2026 01:31:41 +0200 Subject: [PATCH 1/7] feat(auth): implement login history tracking and activity analytics Introduces a new `login_history` table to track successful OAuth token issuances, enabling detailed user activity monitoring and application usage analytics. Key changes: - Added `login_history` table to track user, application, session, IP, and user agent for every login event. - Implemented `recordLogin` service to capture login events during the OAuth flow. - Denormalized `last_login_at` on the `user` table for efficient "last seen" lookups in admin listings. - Added new admin API endpoints for retrieving application activity statistics, login series data, and paginated user login history. - Enhanced frontend with new UI components: `Sparkline` for activity trends, `AppIconStack` for application visualization, and a refactored `DataTable` with improved sorting and density controls. - Updated admin views (Dashboard, Applications, Users, Sessions) to display real-time activity, login history, and enriched user/session metadata. --- drizzle/0001_many_weapon_omega.sql | 14 + drizzle/meta/_journal.json | 7 + frontend/src/api/applications.ts | 12 +- frontend/src/api/stats.ts | 65 + frontend/src/components/ui/AppIconStack.vue | 74 + frontend/src/components/ui/DataTable.vue | 283 +- frontend/src/components/ui/Sparkline.vue | 63 + .../components/users/UserAppHistoryModal.vue | 95 + frontend/src/i18n/en.ts | 34 + frontend/src/i18n/fr.ts | 34 + frontend/src/types/data-table.ts | 40 + frontend/src/types/index.ts | 33 + frontend/src/views/ApplicationDetailView.vue | 158 +- frontend/src/views/ApplicationsView.vue | 41 +- frontend/src/views/DashboardView.vue | 191 +- frontend/src/views/UsersView.vue | 162 +- src/auth.ts | 26 + src/db/auth-schema.ts | 5 + src/db/schema.ts | 27 + src/index.ts | 2 + src/migrate.ts | 11 +- src/routes/admin/applications.ts | 76 +- src/routes/admin/sessions.test.ts | 132 + src/routes/admin/sessions.ts | 67 +- src/routes/admin/stats.test.ts | 172 ++ src/routes/admin/stats.ts | 174 ++ src/routes/admin/users.ts | 76 +- src/services/login-history.test.ts | 66 + src/services/login-history.ts | 38 + test_output.txt | 2466 +++++++++++++++++ 30 files changed, 4469 insertions(+), 175 deletions(-) create mode 100644 drizzle/0001_many_weapon_omega.sql create mode 100644 frontend/src/api/stats.ts create mode 100644 frontend/src/components/ui/AppIconStack.vue create mode 100644 frontend/src/components/ui/Sparkline.vue create mode 100644 frontend/src/components/users/UserAppHistoryModal.vue create mode 100644 frontend/src/types/data-table.ts create mode 100644 src/routes/admin/sessions.test.ts create mode 100644 src/routes/admin/stats.test.ts create mode 100644 src/routes/admin/stats.ts create mode 100644 src/services/login-history.test.ts create mode 100644 src/services/login-history.ts create mode 100644 test_output.txt 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/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/components/ui/AppIconStack.vue b/frontend/src/components/ui/AppIconStack.vue new file mode 100644 index 0000000..e9fe040 --- /dev/null +++ b/frontend/src/components/ui/AppIconStack.vue @@ -0,0 +1,74 @@ + + + diff --git a/frontend/src/components/ui/DataTable.vue b/frontend/src/components/ui/DataTable.vue index b7fc0d9..2ba2ea9 100644 --- a/frontend/src/components/ui/DataTable.vue +++ b/frontend/src/components/ui/DataTable.vue @@ -1,44 +1,269 @@ - 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..f28abe4 100644 --- a/frontend/src/i18n/en.ts +++ b/frontend/src/i18n/en.ts @@ -82,6 +82,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 +128,31 @@ 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', + 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', }, organizations: { title: 'Organizations', @@ -164,6 +193,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..e66b494 100644 --- a/frontend/src/i18n/fr.ts +++ b/frontend/src/i18n/fr.ts @@ -82,6 +82,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 +128,31 @@ 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', + 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é', }, organizations: { title: 'Organisations', @@ -164,6 +193,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/types/data-table.ts b/frontend/src/types/data-table.ts new file mode 100644 index 0000000..e3b1db2 --- /dev/null +++ b/frontend/src/types/data-table.ts @@ -0,0 +1,40 @@ +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'; + 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; +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 639b0ec..533de5d 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 { @@ -146,7 +161,25 @@ export interface UserApplication { 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 +} + +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 { diff --git a/frontend/src/views/ApplicationDetailView.vue b/frontend/src/views/ApplicationDetailView.vue index 1ffc08e..f043cf5 100644 --- a/frontend/src/views/ApplicationDetailView.vue +++ b/frontend/src/views/ApplicationDetailView.vue @@ -20,8 +20,13 @@ 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 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 +78,7 @@ const planFeatureEntries = ref { await services.fetch(); await loadAll(); + void loadAppActivity(); }); async function loadAll() { @@ -107,6 +113,56 @@ 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: 'md' }, + { key: 'plan', label: 'Plan', responsive: 'md' }, + { key: 'status', label: 'Status', responsive: 'lg' }, + { key: 'lastLogin', label: t('users.columns.lastLogin'), field: 'lastLoginAt', sortable: true, responsive: 'lg' }, + { key: 'history', label: t('users.columns.history'), align: 'right' }, + { key: 'actions', label: t('users.columns.actions'), align: 'right' }, +]); + +function formatLastLogin(iso: string | null | undefined): string { + if (!iso) return t('users.never'); + return new Date(iso).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' }); +} + async function handleCreateRole() { formLoading.value = true; try { @@ -585,6 +641,26 @@ const financialKpis = computed(() => { +
+
+
+ + {{ appActivity.online }} + {{ t('applications.activity.online') }} +
+
+
+ + {{ appActivity.last7dLogins }} + {{ t('applications.activity.last7d') }} +
+
+ +
+