diff --git a/app/[locale]/(app)/admin/invite/invite-user-form.tsx b/app/[locale]/(app)/admin/invite/invite-user-form.tsx index b1bf3bf..820052f 100644 --- a/app/[locale]/(app)/admin/invite/invite-user-form.tsx +++ b/app/[locale]/(app)/admin/invite/invite-user-form.tsx @@ -36,6 +36,7 @@ import { inviteUserAction } from "./actions"; export function InviteUserForm() { const t = useTranslations("admin.invite"); + const tRoles = useTranslations("roles"); const [state, dispatchAction, isPending] = useActionState( inviteUserAction, @@ -120,7 +121,7 @@ export function InviteUserForm() { > {appRoles.map((r) => ( ))} @@ -138,12 +139,7 @@ export function InviteUserForm() {

)} - diff --git a/app/[locale]/(app)/dashboard/page.tsx b/app/[locale]/(app)/dashboard/page.tsx index 5ec1bdc..385cbf4 100644 --- a/app/[locale]/(app)/dashboard/page.tsx +++ b/app/[locale]/(app)/dashboard/page.tsx @@ -13,20 +13,23 @@ export default async function DashboardPage({ params }: { params: Promise<{ loca await params; const t = await getTranslations("dashboard"); + const tRoles = await getTranslations("roles"); const session = await getServerSession(); // session is guaranteed non-null by the parent (app)/layout.tsx gate, // but we guard here to satisfy TypeScript's strict null checks. const email = session?.user.email ?? ""; - const role = session?.role ?? ""; + const role = session?.role; + // Clinicians think in names, not emails — greet by full_name when set. + const name = session?.fullName || email; return (
-

{t("welcome", { email })}

+

{t("welcome", { name })}

{t("roleLabel")}{" "} - {role} + {role ? tRoles(role) : ""}

{t("placeholder")}

diff --git a/app/[locale]/(app)/layout.tsx b/app/[locale]/(app)/layout.tsx index b9d5082..55b0824 100644 --- a/app/[locale]/(app)/layout.tsx +++ b/app/[locale]/(app)/layout.tsx @@ -50,10 +50,10 @@ export default async function AppLayout({ return null; } - const { user, role } = session; + const { user, role, fullName } = session; return ( - + {children} ); diff --git a/app/[locale]/(auth)/sign-in/sign-in-form.tsx b/app/[locale]/(auth)/sign-in/sign-in-form.tsx index 0277116..745eeba 100644 --- a/app/[locale]/(auth)/sign-in/sign-in-form.tsx +++ b/app/[locale]/(auth)/sign-in/sign-in-form.tsx @@ -14,10 +14,11 @@ * side or server-side. */ -import { useActionState, useEffect } from "react"; +import { useActionState, useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { useTranslations } from "next-intl"; +import { Eye, EyeOff } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -48,6 +49,10 @@ export function SignInForm() { const { errors: fieldErrors } = form.formState; + // Password reveal toggle — shared clinic PCs + VN keyboards make mistyped + // passwords common; letting the doctor verify what they typed cuts retries. + const [showPassword, setShowPassword] = useState(false); + // Sync server-returned fieldErrors into RHF so the inline error UI is // consistent regardless of where the error originated. useEffect(() => { @@ -83,6 +88,7 @@ export function SignInForm() { - +
+ + +
{fieldErrors.password && (
+ {/* Skip-link: first focusable, jumps keyboard users past the nav. */} + + {t("skipToContent")} + + + {/* Persistent sidebar — desktop only. */} +
{sidebar}
+ + {/* Off-canvas drawer — below md, only when opened. */} + {open && ( +
+
setOpen(false)} + aria-hidden="true" + /> + +
+ )} + +
+ {/* Mobile top bar with hamburger — below md only. */} +
+ + BSK +
+ +
+ {children} +
+
+
+ ); +} diff --git a/components/app-shell/app-shell.tsx b/components/app-shell/app-shell.tsx index 1da9769..a9eec78 100644 --- a/components/app-shell/app-shell.tsx +++ b/components/app-shell/app-shell.tsx @@ -1,30 +1,30 @@ /** * AppShell — Server Component. * - * Composes the full authenticated layout: fixed sidebar on the left, - * scrollable main content area on the right. Receives user info from the - * (app) layout which has already validated session + role. - * - * No client state here — sidebar is server-rendered, top-bar lives inside - * the sidebar's bottom section for Phase 1 simplicity. + * Renders the server-side Sidebar and hands it to AppShellFrame, which owns the + * responsive chrome (persistent at md+, off-canvas drawer below md). Receives + * user info from the (app) layout which has already validated session + role. */ import type { ReactNode } from "react"; import type { AppRole } from "@/lib/db/roles"; import { Sidebar } from "@/components/app-shell/sidebar"; +import { AppShellFrame } from "@/components/app-shell/app-shell-frame"; type AppShellProps = { email: string; + fullName: string | null; role: AppRole; locale: string; children: ReactNode; }; -export function AppShell({ email, role, locale, children }: AppShellProps) { +export function AppShell({ email, fullName, role, locale, children }: AppShellProps) { return ( -
- -
{children}
-
+ } + > + {children} + ); } diff --git a/components/app-shell/locale-switcher.tsx b/components/app-shell/locale-switcher.tsx index 0e9432c..e58ae29 100644 --- a/components/app-shell/locale-switcher.tsx +++ b/components/app-shell/locale-switcher.tsx @@ -8,6 +8,7 @@ * locale prefix rewriting transparently. */ +import { useId } from "react"; import { useLocale, useTranslations } from "next-intl"; import { usePathname, useRouter } from "@/i18n/navigation"; import { routing } from "@/i18n/routing"; @@ -17,6 +18,9 @@ export function LocaleSwitcher() { const router = useRouter(); const pathname = usePathname(); const t = useTranslations("app.localeSwitcher"); + // useId keeps the id unique even when the sidebar is rendered in two DOM + // slots (desktop rail + mobile drawer) — no duplicate id / label collision. + const selectId = useId(); function handleChange(e: React.ChangeEvent) { const nextLocale = e.target.value as (typeof routing.locales)[number]; @@ -25,15 +29,15 @@ export function LocaleSwitcher() { return (
-