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.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))
+}