feat: visibility filtering by user and group on sections and links

This commit is contained in:
maxsoch 2026-07-24 10:15:49 +02:00
parent 1c8284e79d
commit d64fe77d13
9 changed files with 60 additions and 13 deletions

View file

@ -56,7 +56,9 @@ Configuration optionnelle pour l'authentification via un reverse proxy (Authelia
|---|---|---|---|---|
| `logout_url` | Oui | chaîne (URL) | — | URL de déconnexion (ex: `"https://auth.example.com/logout"`) |
| `header_format` | Non | chaîne | `"Remote-User"` | Nom de l'en-tête HTTP indiquant que l'utilisateur est connecté |
| `username` | Non | chaîne | — | Nom de l'en-tête HTTP contenant le nom d'utilisateur (ex: `"Remote-User"`). Affiche le nom dans le header quand renseigné. |
| `username` | Non | chaîne | — | Nom de l'en-tête HTTP contenant le nom d'utilisateur (ex: `"Remote-Name"`). Affiche "Hi {valeur}" dans le header. |
| `header_user` | Non | chaîne | — | Nom de l'en-tête HTTP pour filtrer par utilisateur (ex: `"Remote-User"`) |
| `header_group` | Non | chaîne | — | Nom de l'en-tête HTTP pour filtrer par groupe (ex: `"Remote-Groups"`) |
Quand l'en-tête défini par `header_format` est présent dans la requête, un bouton de déconnexion apparaît à droite du bouton thème.
@ -77,6 +79,8 @@ Tableau de sections. Chaque section contient :
| `description` | Non | chaîne | — | Texte sous le titre |
| `cardLayout` | Non | `"horizontal"` ou `"vertical"` | `"horizontal"` | Disposition des cartes dans la section |
| `links` | Non | tableau | `[]` | Liste des liens |
| `visible_by_user` | Non | chaîne | — | Nom d'utilisateur requis pour voir cette section |
| `visible_by_group` | Non | chaîne | — | Groupe requis pour voir cette section |
```yaml
sections:
@ -101,6 +105,8 @@ Tableau de liens. Chaque lien contient :
| `iconUrl` | Non | chaîne (URL) | — | URL d'une image personnalisée (remplace `icon`) |
| `color` | Non | chaîne (hex) | — | Couleur d'accent (ex: `"#2daae1"`) |
| `openInNewTab` | Non | booléen | `true` | `false` pour ouvrir dans le même onglet |
| `visible_by_user` | Non | chaîne | — | Nom d'utilisateur requis pour voir ce lien |
| `visible_by_group` | Non | chaîne | — | Groupe requis pour voir ce lien |
```yaml
links:

View file

@ -14,12 +14,14 @@ theme:
authentication:
logout_url: "https://auth.socheleau.fr/logout"
header_format: "Remote-User"
username: "Remote-User"
username: "Remote-Name"
header_user: "Remote-User"
header_group: "Remote-Groups"
sections:
- id: "global"
title: "Global"
description: "Services accessibles à toute la famille"
#description: "Services accessibles à toute la famille"
cardLayout: "horizontal"
links:
- id: "nextcloud"
@ -60,7 +62,6 @@ sections:
- id: "custom"
title: "Liens personnalisés"
description: "Autres liens utiles"
cardLayout: "horizontal"
links:
- id: "homeassistant"

View file

@ -33,11 +33,20 @@ export default async function Home() {
const isAuthenticated = config.authentication != null && headersList.has(authHeader)
const username = isAuthenticated && usernameHeader ? headersList.get(usernameHeader) ?? null : null
const userHeader = config.authentication?.header_user
const groupHeader = config.authentication?.header_group
const currentUser = isAuthenticated && userHeader ? headersList.get(userHeader) ?? null : null
const userGroups = isAuthenticated && groupHeader
? (headersList.get(groupHeader) ?? "").split(",").map((g) => g.trim()).filter(Boolean)
: []
return (
<Dashboard
config={config}
isAuthenticated={isAuthenticated}
username={username}
currentUser={currentUser}
userGroups={userGroups}
/>
)
}

View file

@ -6,9 +6,11 @@ interface DashboardProps {
config: DashboardConfig
isAuthenticated?: boolean
username?: string | null
currentUser?: string | null
userGroups?: string[]
}
export function Dashboard({ config, isAuthenticated = false, username = null }: DashboardProps) {
export function Dashboard({ config, isAuthenticated = false, username = null, currentUser = null, userGroups = [] }: DashboardProps) {
return (
<div className="dashboard-root mx-auto flex w-full max-w-7xl flex-col px-4 py-8 sm:px-6 lg:px-8">
<DashboardHeader
@ -20,7 +22,7 @@ export function Dashboard({ config, isAuthenticated = false, username = null }:
/>
<main className="flex flex-col gap-12">
{config.sections.map((section, index) => (
<DashboardSection key={section.id} section={section} sectionIndex={index} />
<DashboardSection key={section.id} section={section} sectionIndex={index} currentUser={currentUser} userGroups={userGroups} />
))}
</main>
{config.site.showFooter !== false && (

View file

@ -52,7 +52,7 @@ export function DashboardHeader({ site, initialTheme, authentication, isAuthenti
<div className="flex items-center gap-3">
{isAuthenticated && username && (
<span className="hidden text-sm text-[var(--text-secondary)] lg:block">
{username}
Hi {username}
</span>
)}
<button
@ -139,7 +139,7 @@ export function DashboardHeader({ site, initialTheme, authentication, isAuthenti
</div>
{isAuthenticated && username && (
<p className="text-sm text-[var(--text-secondary)] lg:hidden">
{username}
Hi {username}
</p>
)}
</header>

View file

@ -1,15 +1,19 @@
import { ServiceCard } from "./ServiceCard"
import { isVisible } from "@/lib/visibility"
import type { SectionConfig } from "@/types/config"
interface DashboardSectionProps {
section: SectionConfig
sectionIndex?: number
currentUser?: string | null
userGroups?: string[]
}
export function DashboardSection({ section, sectionIndex = 0 }: DashboardSectionProps) {
if (section.links.length === 0) {
return null
}
export function DashboardSection({ section, sectionIndex = 0, currentUser = null, userGroups = [] }: DashboardSectionProps) {
if (!isVisible(section, currentUser, userGroups)) return null
const visibleLinks = section.links.filter((link) => isVisible(link, currentUser, userGroups))
if (visibleLinks.length === 0) return null
return (
<section
@ -29,7 +33,7 @@ export function DashboardSection({ section, sectionIndex = 0 }: DashboardSection
)}
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 sm:gap-4">
{section.links.map((link, index) => (
{visibleLinks.map((link, index) => (
<ServiceCard
key={link.id}
link={link}

View file

@ -11,6 +11,8 @@ export const linkSchema = z.object({
iconUrl: z.string().url("Invalid icon URL").optional(),
color: z.string().regex(hexColorRegex, "Invalid hex color").optional(),
openInNewTab: z.boolean().optional().default(true),
visible_by_user: z.string().optional(),
visible_by_group: z.string().optional(),
})
export const sectionSchema = z.object({
@ -19,6 +21,8 @@ export const sectionSchema = z.object({
description: z.string().optional(),
cardLayout: z.enum(["horizontal", "vertical"]).optional().default("horizontal"),
links: z.array(linkSchema).default([]),
visible_by_user: z.string().optional(),
visible_by_group: z.string().optional(),
})
export const themeSchema = z.object({
@ -43,6 +47,8 @@ export const authenticationSchema = z.object({
logout_url: z.string().url("Invalid logout URL").min(1, "Logout URL is required"),
header_format: z.string().optional().default("Remote-User"),
username: z.string().optional(),
header_user: z.string().optional(),
header_group: z.string().optional(),
})
export const dashboardConfigSchema = z.object({

13
src/lib/visibility.ts Normal file
View file

@ -0,0 +1,13 @@
export function isVisible(
item: { visible_by_user?: string; visible_by_group?: string } | undefined | null,
currentUser: string | null,
userGroups: string[]
): boolean {
if (!item) return true
if (!item.visible_by_user && !item.visible_by_group) return true
if (item.visible_by_user && currentUser !== item.visible_by_user) return false
if (item.visible_by_group && !userGroups.includes(item.visible_by_group)) return false
return true
}

View file

@ -22,6 +22,8 @@ export interface LinkConfig {
iconUrl?: string
color?: string
openInNewTab?: boolean
visible_by_user?: string
visible_by_group?: string
}
export interface SectionConfig {
@ -30,12 +32,16 @@ export interface SectionConfig {
description?: string
cardLayout?: "horizontal" | "vertical"
links: LinkConfig[]
visible_by_user?: string
visible_by_group?: string
}
export interface AuthenticationConfig {
logout_url: string
header_format?: string
username?: string
header_user?: string
header_group?: string
}
export interface DashboardConfig {