From 38f4229d3bc49126449294a05338c56d949fe694 Mon Sep 17 00:00:00 2001 From: Randall Stillwell Date: Thu, 12 Mar 2026 10:42:39 -0500 Subject: [PATCH] Add profile page, user dropdown, and Authentik SSO integration - Profile dropdown in header shows user name/email/avatar with links to profile, settings, support, theme submenu, and sign out - New /profile page with editable user info (job title, company, bio) and read-only SSO fields when behind Authentik forward-auth - /api/auth/me endpoint reads X-authentik-* headers and enriches with avatar from Authentik API - Settings page gains Preferences tab with appearance theme picker and notification toggle switches - User profile context backed by localStorage, merges with Authentik data when available Made-with: Cursor --- .env.example | 5 + src/app/api/auth/me/route.ts | 71 ++++ src/app/profile/page.tsx | 316 ++++++++++++++++ src/app/settings/page.tsx | 591 +++++++++++++++++++----------- src/components/layout/top-bar.tsx | 93 ++++- src/components/providers.tsx | 11 +- src/lib/user-profile.tsx | 138 +++++++ 7 files changed, 983 insertions(+), 242 deletions(-) create mode 100644 src/app/api/auth/me/route.ts create mode 100644 src/app/profile/page.tsx create mode 100644 src/lib/user-profile.tsx diff --git a/.env.example b/.env.example index c5dda91..1c8f9b7 100644 --- a/.env.example +++ b/.env.example @@ -16,3 +16,8 @@ MINIO_BUCKET="echos-ocr" # Folder Watch (optional, mount a host path into the container) WATCH_DIR="" + +# Authentik SSO (optional — user info comes from forward-auth headers automatically; +# these are only needed to enrich profiles with avatars via the Authentik API) +AUTHENTIK_URL="https://auth.stillwell.cloud" +AUTHENTIK_API_TOKEN="" diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts new file mode 100644 index 0000000..93bf433 --- /dev/null +++ b/src/app/api/auth/me/route.ts @@ -0,0 +1,71 @@ +import { NextRequest, NextResponse } from "next/server"; + +export type AuthentikUser = { + username: string; + name: string; + email: string; + groups: string[]; + uid: string; + avatar: string; +}; + +/** + * Reads Authentik forward-auth headers injected by Traefik and optionally + * enriches with avatar from the Authentik API. + * + * Headers set by authentik forward-auth: + * X-authentik-username, X-authentik-name, X-authentik-email, + * X-authentik-groups, X-authentik-uid + */ +export async function GET(req: NextRequest) { + const username = req.headers.get("x-authentik-username") ?? ""; + const name = req.headers.get("x-authentik-name") ?? ""; + const email = req.headers.get("x-authentik-email") ?? ""; + const groups = req.headers.get("x-authentik-groups") ?? ""; + const uid = req.headers.get("x-authentik-uid") ?? ""; + + if (!username && !email) { + return NextResponse.json( + { authenticated: false, user: null }, + { status: 200 } + ); + } + + const user: AuthentikUser = { + username, + name, + email, + groups: groups ? groups.split("|") : [], + uid, + avatar: "", + }; + + const authentikUrl = process.env.AUTHENTIK_URL; + const authentikToken = process.env.AUTHENTIK_API_TOKEN; + + if (authentikUrl && authentikToken && uid) { + try { + const res = await fetch( + `${authentikUrl}/api/v3/core/users/?search=${encodeURIComponent(username)}&page_size=1`, + { + headers: { Authorization: `Bearer ${authentikToken}` }, + signal: AbortSignal.timeout(5000), + next: { revalidate: 300 }, + } + ); + + if (res.ok) { + const data = await res.json(); + const matchedUser = data.results?.[0]; + if (matchedUser) { + user.avatar = matchedUser.avatar ?? ""; + if (!user.name && matchedUser.name) user.name = matchedUser.name; + } + } + } catch { + // Authentik API unavailable — headers still provide the essentials + } + } + + return NextResponse.json({ authenticated: true, user }); +} diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx new file mode 100644 index 0000000..d39791b --- /dev/null +++ b/src/app/profile/page.tsx @@ -0,0 +1,316 @@ +"use client"; + +import * as React from "react"; +import { toast } from "sonner"; +import { + Save, + UserCircle, + Camera, + X, + Mail, + Briefcase, + Building2, + Shield, + Loader2, +} from "lucide-react"; + +import { Header } from "@/components/layout/header"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardHeader, + CardTitle, + CardDescription, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Badge } from "@/components/ui/badge"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { useUserProfile } from "@/lib/user-profile"; + +export default function ProfilePage() { + const { profile, updateProfile, initials, authentikUser, isAuthenticated, loading } = useUserProfile(); + + const [form, setForm] = React.useState({ + jobTitle: profile.jobTitle, + company: profile.company, + bio: profile.bio, + // Only editable when NOT coming from Authentik + displayName: profile.displayName, + email: profile.email, + avatarUrl: profile.avatarUrl, + }); + const [dirty, setDirty] = React.useState(false); + + React.useEffect(() => { + setForm({ + jobTitle: profile.jobTitle, + company: profile.company, + bio: profile.bio, + displayName: profile.displayName, + email: profile.email, + avatarUrl: profile.avatarUrl, + }); + }, [profile]); + + const handleChange = (field: string, value: string) => { + setForm((prev) => ({ ...prev, [field]: value })); + setDirty(true); + }; + + const handleSave = () => { + const updates: Record = { + jobTitle: form.jobTitle, + company: form.company, + bio: form.bio, + }; + if (!isAuthenticated) { + updates.displayName = form.displayName; + updates.email = form.email; + updates.avatarUrl = form.avatarUrl; + } + updateProfile(updates); + setDirty(false); + toast.success("Profile updated"); + }; + + const handleAvatarUpload = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + if (!file.type.startsWith("image/")) { + toast.error("Please select an image file"); + return; + } + + if (file.size > 2 * 1024 * 1024) { + toast.error("Image must be under 2 MB"); + return; + } + + const reader = new FileReader(); + reader.onload = () => { + handleChange("avatarUrl", reader.result as string); + }; + reader.readAsDataURL(file); + }; + + const removeAvatar = () => { + handleChange("avatarUrl", ""); + }; + + if (loading) { + return ( +
+ +
+ ); + } + + const nameFromAuthentik = isAuthenticated && !!authentikUser?.name; + const emailFromAuthentik = isAuthenticated && !!authentikUser?.email; + const avatarFromAuthentik = isAuthenticated && !!authentikUser?.avatar; + + return ( +
+
+ +
+ + {isAuthenticated && ( +
+ +

+ Signed in via Authentik as {authentikUser?.username}. + Name, email, and avatar are managed by your identity provider. +

+
+ )} + +
+ + + Photo + + {avatarFromAuthentik + ? "Managed by Authentik" + : "Your profile picture is visible in the header"} + + + +
+ + {form.avatarUrl && ( + + )} + + {initials || } + + + {!avatarFromAuthentik && ( + + )} +
+ {!avatarFromAuthentik && ( +
+ + {form.avatarUrl && ( + + )} +
+ )} + {avatarFromAuthentik && ( +

+ Update your avatar in{" "} + + Authentik + +

+ )} + {!avatarFromAuthentik && ( +

+ JPG, PNG or WebP. Max 2 MB. +

+ )} +
+
+ + + + Personal Information + + {isAuthenticated + ? "Some fields are synced from Authentik" + : "Update your name, email, and other details"} + + + +
+
+ + handleChange("displayName", e.target.value)} + placeholder="Your name" + readOnly={nameFromAuthentik} + className={nameFromAuthentik ? "bg-muted/40 cursor-default" : ""} + /> +
+
+ + handleChange("email", e.target.value)} + placeholder="you@example.com" + readOnly={emailFromAuthentik} + className={emailFromAuthentik ? "bg-muted/40 cursor-default" : ""} + /> +
+
+ + {isAuthenticated && authentikUser?.groups && authentikUser.groups.length > 0 && ( +
+ +
+ {authentikUser.groups.map((group) => ( + + {group} + + ))} +
+
+ )} + +
+
+ + handleChange("jobTitle", e.target.value)} + placeholder="e.g. Project Manager" + /> +
+
+ + handleChange("company", e.target.value)} + placeholder="e.g. Echo Labs" + /> +
+
+ +
+ +