Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions drizzle/0001_many_weapon_omega.sql
Original file line number Diff line number Diff line change
@@ -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");
7 changes: 7 additions & 0 deletions drizzle/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
]
}
12 changes: 11 additions & 1 deletion frontend/src/api/applications.ts
Original file line number Diff line number Diff line change
@@ -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[] }> {
Expand Down Expand Up @@ -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<LoginHistoryResponse> {
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<LoginHistoryResponse>(`/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 };
Expand Down
14 changes: 12 additions & 2 deletions frontend/src/api/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,26 @@ export interface SessionsListResponse {
limit: number
}

export async function listSessions(params: { page?: number; limit?: number } = {}): Promise<SessionsListResponse> {
export async function listSessions(params: { page?: number; limit?: number; search?: string } = {}): Promise<SessionsListResponse> {
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<SessionsListResponse>(`/admin/sessions?${qs}`);
}

Expand Down
65 changes: 65 additions & 0 deletions frontend/src/api/stats.ts
Original file line number Diff line number Diff line change
@@ -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<ActiveUsersResponse> {
if (USE_MOCK) return { count: Math.floor(Math.random() * 12) };
return apiFetch<ActiveUsersResponse>('/admin/stats/active-users');
}

export async function getLogins(params: { range?: '7d' | '30d'; appId?: string } = {}): Promise<LoginsResponse> {
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<LoginsResponse>(`/admin/stats/logins?${qs}`);
}

export async function getApplicationsActivity(): Promise<ApplicationsActivityResponse> {
if (USE_MOCK) {
return {
applications: [],
};
}
return apiFetch<ApplicationsActivityResponse>('/admin/stats/applications-activity');
}
2 changes: 1 addition & 1 deletion frontend/src/api/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export async function getUser(id: string): Promise<UserDetailResponse> {
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<UserDetailResponse>(`/admin/users/${id}`);
Expand Down
126 changes: 126 additions & 0 deletions frontend/src/components/ui/AppIconStack.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
import { AppWindow } from 'lucide-vue-next';
import type { UserAppSummary } from '@/types';

const props = withDefaults(defineProps<{
apps: UserAppSummary[];
/** Maximum number of icons rendered inline before being collapsed into a counter. */
max?: number;
/** Size of each icon avatar in pixels. */
size?: number;
}>(), {
max: 3,
size: 22,
});

const visible = computed(() => props.apps.slice(0, props.max));
const overflow = computed(() => Math.max(0, props.apps.length - props.max));
const open = ref(false);
const triggerRef = ref<HTMLElement | null>(null);
const popoverRef = ref<HTMLElement | null>(null);
const popoverStyle = ref<{ top: string; left: string }>({ top: '0px', left: '0px' });

function updatePosition() {
const t = triggerRef.value;
if (!t) return;
const rect = t.getBoundingClientRect();
popoverStyle.value = {
top: `${rect.bottom + 4}px`,
left: `${rect.left}px`,
};
}

function onDocClick(e: MouseEvent) {
if (!open.value) return;
const target = e.target as Node;
if (popoverRef.value?.contains(target)) return;
if (triggerRef.value?.contains(target)) return;
open.value = false;
}

function onWindowChange() {
if (open.value) open.value = false;
}

async function toggle() {
if (open.value) {
open.value = false;
return;
}
open.value = true;
await nextTick();
updatePosition();
document.addEventListener('click', onDocClick);
window.addEventListener('scroll', onWindowChange, true);
window.addEventListener('resize', onWindowChange);
}

function cleanup() {
document.removeEventListener('click', onDocClick);
window.removeEventListener('scroll', onWindowChange, true);
window.removeEventListener('resize', onWindowChange);
}

onBeforeUnmount(cleanup);

// Tear down the global listeners as soon as the popover closes so we
// never leak handlers after the trigger row scrolls out of view.
watch(open, (v) => { if (!v) cleanup(); });
</script>

<template>
<div v-if="apps.length === 0" class="text-xs text-surface-600">—</div>
<div v-else class="relative inline-flex items-center">
<button
ref="triggerRef"
type="button"
class="inline-flex items-center"
@click.stop="toggle"
>
<span
v-for="(app, idx) in visible"
:key="app.id"
:style="{
width: `${size}px`,
height: `${size}px`,
marginLeft: idx === 0 ? '0' : '-6px',
zIndex: visible.length - idx,
}"
class="inline-flex items-center justify-center rounded-full bg-surface-800 border border-surface-700/60 ring-1 ring-surface-900 overflow-hidden text-surface-300"
:title="app.name"
>
<img v-if="app.icon" :src="app.icon" :alt="app.name" class="w-full h-full object-cover" />
<AppWindow v-else class="w-3 h-3" />
</span>
<span
v-if="overflow > 0"
:style="{ height: `${size}px`, marginLeft: '-6px' }"
class="inline-flex items-center justify-center px-1.5 rounded-full bg-surface-800 border border-surface-700/60 ring-1 ring-surface-900 text-[10px] font-medium text-surface-400"
>
+{{ overflow }}
</span>
</button>

<Teleport to="body">
<div
v-if="open"
ref="popoverRef"
:style="{ position: 'fixed', top: popoverStyle.top, left: popoverStyle.left }"
class="z-[100] w-56 bg-surface-800 border border-surface-700/50 rounded-xl shadow-xl py-1"
>
<div
v-for="app in apps"
:key="app.id"
class="flex items-center gap-2 px-3 py-2 text-sm text-surface-300 hover:bg-surface-700/40"
>
<span class="w-5 h-5 rounded-md bg-surface-700/60 inline-flex items-center justify-center overflow-hidden">
<img v-if="app.icon" :src="app.icon" :alt="app.name" class="w-full h-full object-cover" />
<AppWindow v-else class="w-3 h-3 text-surface-400" />
</span>
<span class="truncate">{{ app.name }}</span>
</div>
</div>
</Teleport>
</div>
</template>
Loading
Loading