diff --git a/apps/frontend/app/page.tsx b/apps/frontend/app/page.tsx index 2afa80d..d18e552 100644 --- a/apps/frontend/app/page.tsx +++ b/apps/frontend/app/page.tsx @@ -1,9 +1,162 @@ -import { Button } from "@/components/ui/button"; +'use client'; + +import { useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { motion } from 'motion/react'; +import { $api } from '@lib/providers/api'; +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 ( +
+ {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 ?? []; -export default function Home() { return ( -
- +
+
+ + {/* Header */} +
+

Applications

+ + + + + + +

{profile.display_name}

+

{profile.email}

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

Your applications

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

+ No applications available +

+
+ )} +
+
+
); } 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/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..0699b58 --- /dev/null +++ b/server/src/routes/api/applications.rs @@ -0,0 +1,71 @@ +use axum::{Extension, Json}; +use futures::TryStreamExt; +use mongodb::bson::doc; +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 = 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)) +}