svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+export {
+ Avatar,
+ AvatarImage,
+ AvatarFallback,
+ AvatarGroup,
+ AvatarGroupCount,
+ AvatarBadge,
+};
diff --git a/backend/dashboard/src/components/ui/card.tsx b/backend/dashboard/src/components/ui/card.tsx
new file mode 100644
index 0000000000..4d7645bd27
--- /dev/null
+++ b/backend/dashboard/src/components/ui/card.tsx
@@ -0,0 +1,103 @@
+import type * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+function Card({
+ className,
+ size = "default",
+ ...props
+}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
+ return (
+
img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function CardAction({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function CardContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+export {
+ Card,
+ CardHeader,
+ CardFooter,
+ CardTitle,
+ CardAction,
+ CardDescription,
+ CardContent,
+};
diff --git a/backend/dashboard/src/components/ui/dropdown-menu.tsx b/backend/dashboard/src/components/ui/dropdown-menu.tsx
new file mode 100644
index 0000000000..d72f8f613c
--- /dev/null
+++ b/backend/dashboard/src/components/ui/dropdown-menu.tsx
@@ -0,0 +1,273 @@
+"use client";
+
+import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
+import type * as React from "react";
+
+import { cn } from "@/lib/utils";
+import { CheckIcon, ChevronRightIcon } from "lucide-react";
+
+function DropdownMenu({
+ ...props
+}: React.ComponentProps
) {
+ return ;
+}
+
+function DropdownMenuPortal({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function DropdownMenuTrigger({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function DropdownMenuContent({
+ className,
+ align = "start",
+ sideOffset = 4,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ );
+}
+
+function DropdownMenuGroup({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function DropdownMenuItem({
+ className,
+ inset,
+ variant = "default",
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean;
+ variant?: "default" | "destructive";
+}) {
+ return (
+
+ );
+}
+
+function DropdownMenuCheckboxItem({
+ className,
+ children,
+ checked,
+ inset,
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean;
+}) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ );
+}
+
+function DropdownMenuRadioGroup({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function DropdownMenuRadioItem({
+ className,
+ children,
+ inset,
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean;
+}) {
+ 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 (
+
+ );
+}
+
+function DropdownMenuSub({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function DropdownMenuSubTrigger({
+ className,
+ inset,
+ children,
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean;
+}) {
+ return (
+
+ {children}
+
+
+ );
+}
+
+function DropdownMenuSubContent({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+export {
+ DropdownMenu,
+ DropdownMenuPortal,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuLabel,
+ DropdownMenuItem,
+ DropdownMenuCheckboxItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubTrigger,
+ DropdownMenuSubContent,
+};
diff --git a/backend/dashboard/src/components/ui/field.tsx b/backend/dashboard/src/components/ui/field.tsx
new file mode 100644
index 0000000000..cbdd27b761
--- /dev/null
+++ b/backend/dashboard/src/components/ui/field.tsx
@@ -0,0 +1,236 @@
+import { type VariantProps, cva } from "class-variance-authority";
+import { useMemo } from "react";
+
+import { Label } from "@/components/ui/label";
+import { Separator } from "@/components/ui/separator";
+import { cn } from "@/lib/utils";
+
+function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
+ return (
+ [data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function FieldLegend({
+ className,
+ variant = "legend",
+ ...props
+}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
+ return (
+
+ );
+}
+
+function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+const fieldVariants = cva(
+ "group/field flex w-full gap-2 data-[invalid=true]:text-destructive",
+ {
+ variants: {
+ orientation: {
+ vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
+ horizontal:
+ "flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
+ responsive:
+ "flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
+ },
+ },
+ defaultVariants: {
+ orientation: "vertical",
+ },
+ },
+);
+
+function Field({
+ className,
+ orientation = "vertical",
+ ...props
+}: React.ComponentProps<"div"> & VariantProps) {
+ return (
+
+ );
+}
+
+function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function FieldLabel({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+ [data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border *:data-[slot=field]:p-2.5 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10",
+ "has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
+ return (
+ a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function FieldSeparator({
+ children,
+ className,
+ ...props
+}: React.ComponentProps<"div"> & {
+ children?: React.ReactNode;
+}) {
+ return (
+
+
+ {children && (
+
+ {children}
+
+ )}
+
+ );
+}
+
+function FieldError({
+ className,
+ children,
+ errors,
+ ...props
+}: React.ComponentProps<"div"> & {
+ errors?: Array<{ message?: string } | undefined>;
+}) {
+ const content = useMemo(() => {
+ if (children) {
+ return children;
+ }
+
+ if (!errors?.length) {
+ return null;
+ }
+
+ const uniqueErrors = [
+ ...new Map(errors.map((error) => [error?.message, error])).values(),
+ ];
+
+ if (uniqueErrors?.length === 1) {
+ return uniqueErrors[0]?.message;
+ }
+
+ return (
+
+ {uniqueErrors.map(
+ (error, index) =>
+ error?.message && {error.message} ,
+ )}
+
+ );
+ }, [children, errors]);
+
+ if (!content) {
+ return null;
+ }
+
+ return (
+
+ {content}
+
+ );
+}
+
+export {
+ Field,
+ FieldLabel,
+ FieldDescription,
+ FieldError,
+ FieldGroup,
+ FieldLegend,
+ FieldSeparator,
+ FieldSet,
+ FieldContent,
+ FieldTitle,
+};
diff --git a/backend/dashboard/src/components/ui/label.tsx b/backend/dashboard/src/components/ui/label.tsx
new file mode 100644
index 0000000000..68aaba0c66
--- /dev/null
+++ b/backend/dashboard/src/components/ui/label.tsx
@@ -0,0 +1,22 @@
+import { Label as LabelPrimitive } from "radix-ui";
+import type * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+function Label({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+export { Label };
diff --git a/backend/dashboard/src/pages/Dashboard.tsx b/backend/dashboard/src/pages/Dashboard.tsx
index 0153f6bf8c..1f2079ead1 100644
--- a/backend/dashboard/src/pages/Dashboard.tsx
+++ b/backend/dashboard/src/pages/Dashboard.tsx
@@ -1,4 +1,5 @@
import { AppSidebar } from "@/components/app-sidebar";
+import type { NavUserData } from "@/components/nav-user";
import { Separator } from "@/components/ui/separator";
import {
SidebarInset,
@@ -7,11 +8,11 @@ import {
} from "@/components/ui/sidebar";
import { TooltipProvider } from "@/components/ui/tooltip";
-export default function Dashboard() {
+export default function Dashboard({ user }: { user: NavUserData }) {
return (
-
+
diff --git a/backend/dashboard/src/pages/Login.tsx b/backend/dashboard/src/pages/Login.tsx
new file mode 100644
index 0000000000..dc7525f4c2
--- /dev/null
+++ b/backend/dashboard/src/pages/Login.tsx
@@ -0,0 +1,25 @@
+import { Link } from "@inertiajs/react";
+
+import { LoginForm } from "@/components/login-form";
+
+export default function Login({ nextUrl }: { nextUrl: string }) {
+ return (
+
+
+
+
+ 26
+
+ PyCon Italia 2026
+
+
+
+
+ );
+}
diff --git a/backend/dashboard/src/styles.css b/backend/dashboard/src/styles.css
index e49f238da9..7779f99eaf 100644
--- a/backend/dashboard/src/styles.css
+++ b/backend/dashboard/src/styles.css
@@ -1,7 +1,7 @@
+@import "@fontsource-variable/geist";
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
-@import "@fontsource-variable/geist";
@custom-variant dark (&:is(.dark *));
diff --git a/backend/dashboard/tests/test_views.py b/backend/dashboard/tests/test_views.py
index 3c3f052fde..104494a2dd 100644
--- a/backend/dashboard/tests/test_views.py
+++ b/backend/dashboard/tests/test_views.py
@@ -1,4 +1,16 @@
-def test_dashboard_renders_inertia_page(client):
+def test_dashboard_requires_authentication(client):
+ response = client.get("/dashboard")
+ inertia_response = client.get("/dashboard", headers={"X-Inertia": "true"})
+
+ assert response.status_code == 302
+ assert response.url == "/dashboard/login?next=/dashboard"
+ assert inertia_response.status_code == 302
+ assert inertia_response.url == "/dashboard/login?next=/dashboard"
+
+
+def test_dashboard_renders_inertia_page(client, user):
+ client.force_login(user)
+
response = client.get("/dashboard")
assert response.status_code == 200
@@ -6,9 +18,63 @@ def test_dashboard_renders_inertia_page(client):
assert b'"component": "Dashboard"' in response.content
-def test_dashboard_supports_inertia_requests(client):
+def test_dashboard_supports_inertia_requests(client, user):
+ client.force_login(user)
+
response = client.get("/dashboard", headers={"X-Inertia": "true"})
assert response.status_code == 200
assert response.headers["X-Inertia"] == "true"
assert response.json()["component"] == "Dashboard"
+ assert response.json()["props"]["user"]["email"] == "simulated@user.it"
+
+
+def test_dashboard_shares_authenticated_user(client, user):
+ client.force_login(user)
+
+ response = client.get("/dashboard", headers={"X-Inertia": "true"})
+
+ assert response.json()["props"]["user"] == {
+ "name": "Jane Doe",
+ "email": "simulated@user.it",
+ "avatar": None,
+ }
+
+
+def test_dashboard_login_renders_inertia_page(client):
+ response = client.get("/dashboard/login")
+
+ assert response.status_code == 200
+ assert "X-Inertia" in response.headers["Vary"]
+ assert b'"component": "Login"' in response.content
+
+
+def test_dashboard_login_supports_inertia_requests(client):
+ response = client.get(
+ "/dashboard/login?next=/dashboard%3Fsection%3Dprofile",
+ headers={"X-Inertia": "true"},
+ )
+
+ assert response.status_code == 200
+ assert response.headers["X-Inertia"] == "true"
+ assert response.json()["component"] == "Login"
+ assert response.json()["props"]["nextUrl"] == "/dashboard?section=profile"
+
+
+def test_dashboard_login_rejects_external_next_url(client):
+ response = client.get(
+ "/dashboard/login?next=https://example.com",
+ headers={"X-Inertia": "true"},
+ )
+
+ assert response.status_code == 200
+ assert response.json()["props"]["nextUrl"] == "/dashboard"
+
+
+def test_dashboard_login_redirects_authenticated_user(client, user):
+ client.force_login(user)
+
+ response = client.get("/dashboard/login?next=/dashboard%3Fsection%3Dprofile")
+
+ assert response.status_code == 302
+ assert response.url == "/dashboard?section=profile"
diff --git a/backend/dashboard/views.py b/backend/dashboard/views.py
index cdec80c133..38f2e4d307 100644
--- a/backend/dashboard/views.py
+++ b/backend/dashboard/views.py
@@ -1,6 +1,44 @@
from cross_inertia.django import render
+from django.contrib.auth.decorators import login_required
from django.http import HttpRequest, HttpResponse
+from django.shortcuts import redirect
+from django.utils.http import url_has_allowed_host_and_scheme
+def _get_next_url(request: HttpRequest) -> str:
+ next_url = request.GET.get("next", "/dashboard")
+
+ if next_url.startswith("/") and url_has_allowed_host_and_scheme(
+ next_url,
+ allowed_hosts={request.get_host()},
+ require_https=request.is_secure(),
+ ):
+ return next_url
+
+ return "/dashboard"
+
+
+@login_required(login_url="dashboard-login")
def dashboard(request: HttpRequest) -> HttpResponse:
- return render(request, "Dashboard")
+ user = request.user
+
+ return render(
+ request,
+ "Dashboard",
+ {
+ "user": {
+ "name": user.display_name or user.email,
+ "email": user.email,
+ "avatar": None,
+ }
+ },
+ )
+
+
+def login(request: HttpRequest) -> HttpResponse:
+ next_url = _get_next_url(request)
+
+ if request.user.is_authenticated:
+ return redirect(next_url)
+
+ return render(request, "Login", {"nextUrl": next_url})
diff --git a/backend/dashboard/vite.config.ts b/backend/dashboard/vite.config.ts
index 81c2a7f348..75f5441cae 100644
--- a/backend/dashboard/vite.config.ts
+++ b/backend/dashboard/vite.config.ts
@@ -19,6 +19,7 @@ export default defineConfig({
},
server: {
host: "0.0.0.0",
+ origin: "http://localhost:5173",
port: 5173,
strictPort: true,
},
diff --git a/backend/pycon/urls.py b/backend/pycon/urls.py
index a02b8dcd8e..ed6fb1e9e9 100644
--- a/backend/pycon/urls.py
+++ b/backend/pycon/urls.py
@@ -6,6 +6,7 @@
from api.schema import schema
from api.views import GraphQLView
+from dashboard.views import login as dashboard_login
from pycon.views import disabled_view
from submissions.views import SubmissionAutocomplete
from wagtail.admin import urls as wagtailadmin_urls
@@ -13,6 +14,7 @@
from wagtail import urls as wagtail_urls
urlpatterns = [
+ path("dashboard/login", dashboard_login, name="dashboard-login"),
path("dashboard", include("dashboard.urls")),
path(
"admin/_/submission-autocomplete",