From 22c84917e68b8a521e1d81d8daf47e151feefbca Mon Sep 17 00:00:00 2001 From: PawiX25 Date: Tue, 7 Apr 2026 15:17:59 +0200 Subject: [PATCH 1/2] feat: add portal page with application grid and settings navigation - Add user-facing GET /api/applications endpoint that returns applications filtered by user's group membership - Replace placeholder root page with portal showing: - Welcome header with user info and sign out - Application grid with group-based access control - Account Settings card linking to /dashboard - Add UserApplication schema to API types --- apps/frontend/app/page.tsx | 151 +++++++++++++++++++++++++- packages/api-schema/api.d.ts | 60 ++++++++++ server/src/routes/api.rs | 2 + server/src/routes/api/applications.rs | 71 ++++++++++++ 4 files changed, 280 insertions(+), 4 deletions(-) create mode 100644 server/src/routes/api/applications.rs diff --git a/apps/frontend/app/page.tsx b/apps/frontend/app/page.tsx index 2afa80d..4f736f3 100644 --- a/apps/frontend/app/page.tsx +++ b/apps/frontend/app/page.tsx @@ -1,9 +1,152 @@ -import { Button } from "@/components/ui/button"; +'use client'; -export default function Home() { +import { useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { motion } from 'motion/react'; +import { $api } from '@lib/providers/api'; +import { IconSettings, IconLayoutGrid, IconLogout, IconArrowRight } from '@tabler/icons-react'; +import { Button } from '@components/ui/button'; +import Link from 'next/link'; + +function AppCard({ name, icon }: { name: string; icon?: string | null }) { + return ( +
+ {icon ? ( + {name} + ) : ( +
+ + {name.charAt(0).toUpperCase()} + +
+ )} + {name} +
+ ); +} + +function PortalSkeleton() { return ( -
- +
+
+
+ ); +} + +export default function HomePage() { + const router = useRouter(); + + const profileQuery = $api.useQuery( + 'get', + '/api/settings/profile', + {}, + { + retry: false, + refetchOnWindowFocus: false, + } + ); + + const appsQuery = $api.useQuery( + 'get', + '/api/applications', + {}, + { + retry: false, + refetchOnWindowFocus: false, + enabled: !!profileQuery.data, + } + ); + + useEffect(() => { + const err = profileQuery.error as unknown as { error?: string } | null; + if (err?.error === 'Unauthorized') { + router.replace('/login'); + } + }, [profileQuery.error, router]); + + const logoutMutation = $api.useMutation('post', '/api/logout', { + onSuccess: () => router.replace('/login'), + }); + + if (profileQuery.isLoading || (!profileQuery.data && !profileQuery.error)) { + return ; + } + + const profile = profileQuery.data; + if (!profile) return null; + + const apps = appsQuery.data ?? []; + + return ( +
+
+ + {/* Header */} +
+
+

+ Welcome back, {profile.display_name} +

+

{profile.email}

+
+ +
+ + {/* Applications */} +
+
+ +

Applications

+
+ {apps.length > 0 ? ( +
+ {apps.map((app) => ( + + ))} +
+ ) : ( +
+ +

+ No applications available +

+
+ )} +
+ + {/* Account Settings Card */} + +
+
+
+ +
+
+

Account Settings

+

+ Security, sessions, and profile +

+
+
+ +
+ +
+
); } diff --git a/packages/api-schema/api.d.ts b/packages/api-schema/api.d.ts index c2a256c..685fdb7 100644 --- a/packages/api-schema/api.d.ts +++ b/packages/api-schema/api.d.ts @@ -38,6 +38,23 @@ export interface paths { patch?: never; trace?: never; }; + "/api/applications": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get applications available to the current user */ + get: operations["get_my_applications"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/auth/factors/password/authenticate": { parameters: { query?: never; @@ -1113,6 +1130,11 @@ export interface components { redirect_uris: string[]; slug: string; }; + UserApplication: { + icon?: string | null; + name: string; + slug: string; + }; PublicAuthFactors: { password: components["schemas"]["PublicPasswordFactor"]; pgp: components["schemas"]["PublicPGPFactor"][]; @@ -1575,6 +1597,44 @@ export interface operations { }; }; }; + get_my_applications: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserApplication"][]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UnauthorizedError"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ForbiddenError"]; + }; + }; + }; + }; password_authenticate: { parameters: { query?: never; diff --git a/server/src/routes/api.rs b/server/src/routes/api.rs index 3162963..eb0a040 100644 --- a/server/src/routes/api.rs +++ b/server/src/routes/api.rs @@ -1,4 +1,5 @@ mod admin; +mod applications; mod confirm_email; mod health; mod login; @@ -19,6 +20,7 @@ use crate::{middlewares::require_auth::require_auth, state::AppState}; pub fn routes() -> OpenApiRouter { let auth = OpenApiRouter::new() .nest("/admin", admin::routes()) + .nest("/applications", applications::routes()) .nest("/logout", logout::routes()) .nest("/settings", settings::routes()) .layer(middleware::from_fn(require_auth)); diff --git a/server/src/routes/api/applications.rs b/server/src/routes/api/applications.rs new file mode 100644 index 0000000..7e39c9b --- /dev/null +++ b/server/src/routes/api/applications.rs @@ -0,0 +1,71 @@ +use axum::{Extension, Json}; +use futures::TryStreamExt; +use mongodb::bson::{doc, oid::ObjectId}; +use serde::Serialize; +use utoipa::ToSchema; +use utoipa_axum::{router::OpenApiRouter, routes}; + +use crate::{ + axum_error::AxumResult, + database::{Application, User}, + middlewares::require_auth::{ForbiddenError, UnauthorizedError, UserId}, + state::AppState, +}; + +pub fn routes() -> OpenApiRouter { + OpenApiRouter::new().routes(routes!(get_my_applications)) +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct UserApplication { + pub name: String, + pub slug: String, + pub icon: Option, +} + +/// Get applications available to the current user +#[utoipa::path( + method(get), + path = "/", + responses( + (status = OK, description = "Success", body = Vec, content_type = "application/json"), + (status = UNAUTHORIZED, description = "Unauthorized", body = UnauthorizedError, content_type = "application/json"), + (status = FORBIDDEN, description = "Forbidden", body = ForbiddenError, content_type = "application/json"), + ), + tag = "Applications" +)] +async fn get_my_applications( + Extension(state): Extension, + Extension(user_id): Extension, +) -> AxumResult>> { + let user = state + .database + .collection::("users") + .find_one(doc! { "_id": *user_id }) + .await?; + + let user_groups: Vec = user.map(|u| u.groups).unwrap_or_default(); + + let apps: Vec = state + .database + .collection::("applications") + .find(doc! {}) + .await? + .try_collect() + .await?; + + let visible_apps = apps + .into_iter() + .filter(|app| { + app.allowed_groups.is_empty() + || app.allowed_groups.iter().any(|g| user_groups.contains(g)) + }) + .map(|app| UserApplication { + name: app.name, + slug: app.slug, + icon: app.icon, + }) + .collect(); + + Ok(Json(visible_apps)) +} From 5c6b2e65e06920c12ab5bb0dfa49adb5f3b8dc0f Mon Sep 17 00:00:00 2001 From: PawiX25 Date: Tue, 7 Apr 2026 17:32:02 +0200 Subject: [PATCH 2/2] feat: replace header actions with user dropdown on portal page - Add user dropdown (avatar initial, display name) replacing the separate Sign out button and Account Settings card; shows account settings link and log out option - Add shared DropdownMenu UI component (Radix UI primitives) - Remove explicitly annotated types that Rust can infer in applications.rs - Unify log out label with dashboard (was "Sign out") --- apps/frontend/app/page.tsx | 84 +++---- apps/frontend/components/ui/dropdown-menu.tsx | 206 ++++++++++++++++++ server/src/routes/api/applications.rs | 6 +- 3 files changed, 256 insertions(+), 40 deletions(-) create mode 100644 apps/frontend/components/ui/dropdown-menu.tsx diff --git a/apps/frontend/app/page.tsx b/apps/frontend/app/page.tsx index 4f736f3..d18e552 100644 --- a/apps/frontend/app/page.tsx +++ b/apps/frontend/app/page.tsx @@ -4,9 +4,17 @@ import { useEffect } from 'react'; import { useRouter } from 'next/navigation'; import { motion } from 'motion/react'; import { $api } from '@lib/providers/api'; -import { IconSettings, IconLayoutGrid, IconLogout, IconArrowRight } from '@tabler/icons-react'; -import { Button } from '@components/ui/button'; +import { IconLayoutGrid, IconSettings, IconLogout, IconChevronDown } from '@tabler/icons-react'; import Link from 'next/link'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@components/ui/dropdown-menu'; +import { Button } from '@components/ui/button'; function AppCard({ name, icon }: { name: string; icon?: string | null }) { return ( @@ -88,29 +96,49 @@ export default function HomePage() { > {/* Header */}
-
-

- Welcome back, {profile.display_name} -

-

{profile.email}

-
- +

Applications

+ + + + + + +

{profile.display_name}

+

{profile.email}

+
+ + + + + Account Settings + + + + logoutMutation.mutate({})} + > + + Log out + +
+
{/* Applications */}
-

Applications

+

Your applications

{apps.length > 0 ? (
@@ -127,24 +155,6 @@ export default function HomePage() {
)}
- - {/* Account Settings Card */} - -
-
-
- -
-
-

Account Settings

-

- Security, sessions, and profile -

-
-
- -
-
diff --git a/apps/frontend/components/ui/dropdown-menu.tsx b/apps/frontend/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..1d7b638 --- /dev/null +++ b/apps/frontend/components/ui/dropdown-menu.tsx @@ -0,0 +1,206 @@ +import * as React from 'react'; +import { DropdownMenu as DropdownMenuPrimitive } from 'radix-ui'; +import { IconCheck, IconChevronRight, IconCircle } from '@tabler/icons-react'; + +import { cn } from '@/lib/utils'; + +const DropdownMenu = DropdownMenuPrimitive.Root; +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; +const DropdownMenuGroup = DropdownMenuPrimitive.Group; +const DropdownMenuPortal = DropdownMenuPrimitive.Portal; +const DropdownMenuSub = DropdownMenuPrimitive.Sub; +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + {children} + + + ); +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuContent({ + className, + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +function DropdownMenuItem({ + className, + inset, + variant = 'default', + ...props +}: React.ComponentProps & { + inset?: boolean; + variant?: 'default' | 'destructive'; +}) { + return ( + + ); +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function DropdownMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + ); +} + +function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) { + return ( + + ); +} + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +}; diff --git a/server/src/routes/api/applications.rs b/server/src/routes/api/applications.rs index 7e39c9b..0699b58 100644 --- a/server/src/routes/api/applications.rs +++ b/server/src/routes/api/applications.rs @@ -1,6 +1,6 @@ use axum::{Extension, Json}; use futures::TryStreamExt; -use mongodb::bson::{doc, oid::ObjectId}; +use mongodb::bson::doc; use serde::Serialize; use utoipa::ToSchema; use utoipa_axum::{router::OpenApiRouter, routes}; @@ -44,11 +44,11 @@ async fn get_my_applications( .find_one(doc! { "_id": *user_id }) .await?; - let user_groups: Vec = user.map(|u| u.groups).unwrap_or_default(); + let user_groups = user.map(|u| u.groups).unwrap_or_default(); let apps: Vec = state .database - .collection::("applications") + .collection("applications") .find(doc! {}) .await? .try_collect()